@notur/sdk 1.4.4 → 1.4.6

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.
package/README.md CHANGED
@@ -94,7 +94,56 @@ import type {
94
94
 
95
95
  ## CLI Tools
96
96
 
97
- The SDK ships two CLI tools, available via `npx`, `yarn dlx`, `pnpm dlx`, or `bunx`.
97
+ The SDK ships local development CLI tools, available via `npx`, `yarn dlx`, `pnpm dlx`, or `bunx`.
98
+
99
+ You can use the dispatcher:
100
+
101
+ ```bash
102
+ npx @notur/sdk create acme/red-button
103
+ npx @notur/sdk pack
104
+ npx @notur/sdk push --host https://panel.example.com --key notur_xxx
105
+ ```
106
+
107
+ Or the standalone bins:
108
+
109
+ ```bash
110
+ npx notur-create acme/red-button
111
+ npx notur-pack
112
+ npx notur-push --host https://panel.example.com --key notur_xxx
113
+ ```
114
+
115
+ ### `notur-create` — Scaffold extensions locally
116
+
117
+ Creates a Notur extension folder without requiring Pterodactyl, Laravel, or `php artisan`.
118
+
119
+ Run without arguments to start the interactive wizard:
120
+
121
+ ```bash
122
+ npx notur-create
123
+ ```
124
+
125
+ Scripted usage stays non-interactive:
126
+
127
+ ```bash
128
+ npx notur-create acme/red-button --slot server.header
129
+ cd red-button
130
+ npm install
131
+ npm run build
132
+ npx notur-pack
133
+ ```
134
+
135
+ Options:
136
+ - `--path <dir>` — parent directory for the generated extension.
137
+ - `--preset <name>` — `frontend`, `backend`, `full`, or `minimal`.
138
+ - `--name <name>` — display name for `extension.yaml`.
139
+ - `--description <text>` — description for `extension.yaml`.
140
+ - `--slot <slot>` — initial frontend slot, default `dashboard.widgets`.
141
+ - `--package-manager <name>` — `npm`, `pnpm`, `yarn`, or `bun`.
142
+ - `--install` / `--no-install` — install frontend dependencies after scaffolding.
143
+ - `--env` / `--no-env` — create `.env` from `.env.example`.
144
+ - `--no-frontend` — generate only `extension.yaml` and the PHP entrypoint.
145
+ - `--with-api-routes` — include a client API route stub.
146
+ - `--force` — allow writing into an existing empty directory.
98
147
 
99
148
  ### `notur-pack` — Package extensions
100
149
 
@@ -137,6 +186,48 @@ This additionally produces:
137
186
 
138
187
  The `.sig` format is compatible with PHP's `SignatureVerifier::verify()`.
139
188
 
189
+ ### `notur-push` — Push to a remote Notur panel
190
+
191
+ Packages the local extension and uploads it to a Notur-enabled Pterodactyl panel using a remote push key.
192
+
193
+ ```bash
194
+ npx notur-push --host https://panel.example.com --key notur_xxx
195
+ ```
196
+
197
+ Equivalent values can be provided by the shell or by a local `.env` file in the extension directory:
198
+
199
+ ```dotenv
200
+ NOTUR_HOST=https://panel.example.com
201
+ NOTUR_PUSH_KEY=notur_xxx
202
+ ```
203
+
204
+ Then run:
205
+
206
+ ```bash
207
+ npm run push
208
+ ```
209
+
210
+ or:
211
+
212
+ ```bash
213
+ npx notur-push
214
+ ```
215
+
216
+ Remote setup on the panel:
217
+
218
+ ```bash
219
+ php artisan notur:remote-key
220
+ ```
221
+
222
+ Add the printed `NOTUR_REMOTE_PUSH_ENABLED=true` and `NOTUR_REMOTE_PUSH_KEYS=...` values to the panel `.env`.
223
+
224
+ Options:
225
+ - `--archive <file>` — upload an existing `.notur` archive instead of packing.
226
+ - `--env-file <file>` — load host/key values from a custom env file.
227
+ - `--no-build` — skip running the local `build` script before packing.
228
+ - `--no-force` — do not overwrite an already installed extension.
229
+ - `--keep-archive` — keep the temporary archive generated for the push.
230
+
140
231
  ### `notur-keygen` — Generate signing keypair
141
232
 
142
233
  Generates a new Ed25519 keypair for extension signing.
@@ -0,0 +1,580 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const path = require('path');
5
+ const readline = require('readline');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ function parseArgs() {
9
+ const args = process.argv.slice(2);
10
+ const options = {
11
+ id: null,
12
+ path: process.cwd(),
13
+ preset: null,
14
+ displayName: null,
15
+ description: null,
16
+ withFrontend: true,
17
+ withApiRoutes: false,
18
+ force: false,
19
+ slot: 'dashboard.widgets',
20
+ packageManager: null,
21
+ install: null,
22
+ createEnv: null,
23
+ };
24
+
25
+ for (let i = 0; i < args.length; i++) {
26
+ const arg = args[i];
27
+ if (arg === '--path') {
28
+ options.path = args[++i];
29
+ } else if (arg === '--preset') {
30
+ options.preset = args[++i];
31
+ } else if (arg === '--name' || arg === '--display-name') {
32
+ options.displayName = args[++i];
33
+ } else if (arg === '--description') {
34
+ options.description = args[++i];
35
+ } else if (arg === '--with-frontend') {
36
+ options.withFrontend = true;
37
+ } else if (arg === '--no-frontend') {
38
+ options.withFrontend = false;
39
+ } else if (arg === '--with-api-routes') {
40
+ options.withApiRoutes = true;
41
+ } else if (arg === '--no-api-routes') {
42
+ options.withApiRoutes = false;
43
+ } else if (arg === '--force') {
44
+ options.force = true;
45
+ } else if (arg === '--slot') {
46
+ options.slot = args[++i];
47
+ } else if (arg === '--package-manager') {
48
+ options.packageManager = args[++i];
49
+ } else if (arg === '--install') {
50
+ options.install = true;
51
+ } else if (arg === '--no-install') {
52
+ options.install = false;
53
+ } else if (arg === '--env') {
54
+ options.createEnv = true;
55
+ } else if (arg === '--no-env') {
56
+ options.createEnv = false;
57
+ } else if (arg === '--help' || arg === '-h') {
58
+ usage(0);
59
+ } else if (!arg.startsWith('-') && !options.id) {
60
+ options.id = arg;
61
+ } else {
62
+ console.error(`Unknown argument: ${arg}`);
63
+ usage(1);
64
+ }
65
+ }
66
+
67
+ if (options.preset && !['frontend', 'backend', 'full', 'minimal'].includes(options.preset)) {
68
+ console.error('Error: preset must be one of frontend, backend, full, or minimal.');
69
+ process.exit(1);
70
+ }
71
+
72
+ if (options.packageManager && !['npm', 'pnpm', 'yarn', 'bun'].includes(options.packageManager)) {
73
+ console.error('Error: package manager must be one of npm, pnpm, yarn, or bun.');
74
+ process.exit(1);
75
+ }
76
+
77
+ return options;
78
+ }
79
+
80
+ function usage(code) {
81
+ console.log(`Usage:
82
+ npx notur-create acme/red-button [options]
83
+ npx @notur/sdk create acme/red-button [options]
84
+
85
+ Options:
86
+ --path <dir> Parent directory for the generated extension
87
+ --preset <name> frontend, backend, full, or minimal
88
+ --name <name> Display name for extension.yaml
89
+ --description <txt> Description for extension.yaml
90
+ --slot <slot> Initial frontend slot (default: dashboard.widgets)
91
+ --package-manager npm, pnpm, yarn, or bun
92
+ --install Install frontend dependencies after scaffolding
93
+ --env Create .env from .env.example
94
+ --no-frontend Generate PHP/manifest only
95
+ --with-api-routes Include a client API route stub
96
+ --force Allow writing into an existing empty directory`);
97
+ process.exit(code);
98
+ }
99
+
100
+ function isInteractive() {
101
+ return process.stdin.isTTY && process.stdout.isTTY;
102
+ }
103
+
104
+ function prompt(question, defaultValue = '') {
105
+ const suffix = defaultValue ? ` (${defaultValue})` : '';
106
+ const rl = readline.createInterface({
107
+ input: process.stdin,
108
+ output: process.stdout,
109
+ });
110
+
111
+ return new Promise(resolve => {
112
+ rl.question(`${question}${suffix}: `, answer => {
113
+ rl.close();
114
+ resolve(answer.trim() || defaultValue);
115
+ });
116
+ });
117
+ }
118
+
119
+ async function select(question, choices, defaultChoice) {
120
+ const labels = choices.map(choice => choice.value).join('/');
121
+ while (true) {
122
+ const answer = await prompt(`${question} [${labels}]`, defaultChoice);
123
+ const match = choices.find(choice => choice.value === answer);
124
+ if (match) {
125
+ return match.value;
126
+ }
127
+ console.log(`Choose one of: ${labels}`);
128
+ }
129
+ }
130
+
131
+ async function confirm(question, defaultValue = false) {
132
+ const answer = await prompt(`${question} [${defaultValue ? 'Y/n' : 'y/N'}]`, defaultValue ? 'y' : 'n');
133
+ return ['y', 'yes'].includes(answer.toLowerCase());
134
+ }
135
+
136
+ function validateId(id) {
137
+ return /^[a-z0-9-]+\/[a-z0-9-]+$/.test(id);
138
+ }
139
+
140
+ function applyPreset(options) {
141
+ const preset = options.preset || 'frontend';
142
+ const features = {
143
+ frontend: true,
144
+ apiRoutes: false,
145
+ };
146
+
147
+ if (preset === 'minimal') {
148
+ features.frontend = false;
149
+ features.apiRoutes = false;
150
+ } else if (preset === 'backend') {
151
+ features.frontend = false;
152
+ features.apiRoutes = true;
153
+ } else if (preset === 'full') {
154
+ features.frontend = true;
155
+ features.apiRoutes = true;
156
+ }
157
+
158
+ if (options.withFrontend === false) {
159
+ features.frontend = false;
160
+ } else if (options.withFrontend === true && options.preset === null) {
161
+ features.frontend = true;
162
+ }
163
+
164
+ if (options.withApiRoutes === true) {
165
+ features.apiRoutes = true;
166
+ } else if (options.withApiRoutes === false && options.preset === null) {
167
+ features.apiRoutes = false;
168
+ }
169
+
170
+ options.withFrontend = features.frontend;
171
+ options.withApiRoutes = features.apiRoutes;
172
+ options.preset = preset;
173
+
174
+ return options;
175
+ }
176
+
177
+ async function resolveOptions(options) {
178
+ if (!options.id && !isInteractive()) {
179
+ console.error('Error: extension id is required in non-interactive mode.');
180
+ usage(1);
181
+ }
182
+
183
+ if (options.id && !validateId(options.id)) {
184
+ console.error('Error: extension id must use vendor/name format with lowercase letters, numbers, and hyphens.');
185
+ process.exit(1);
186
+ }
187
+
188
+ if (!options.id) {
189
+ console.log('Notur extension setup');
190
+ while (!options.id) {
191
+ const id = await prompt('Extension id', 'acme/red-button');
192
+ if (validateId(id)) {
193
+ options.id = id;
194
+ } else {
195
+ console.log('Use vendor/name format with lowercase letters, numbers, and hyphens.');
196
+ }
197
+ }
198
+ }
199
+
200
+ const [, name] = options.id.split('/');
201
+ if (!options.displayName && isInteractive()) {
202
+ options.displayName = await prompt('Display name', displayName(name));
203
+ }
204
+ if (!options.description && isInteractive()) {
205
+ options.description = await prompt('Description', 'A Notur extension');
206
+ }
207
+ if (!options.preset && isInteractive()) {
208
+ options.preset = await select('Preset', [
209
+ { value: 'frontend' },
210
+ { value: 'backend' },
211
+ { value: 'full' },
212
+ { value: 'minimal' },
213
+ ], 'frontend');
214
+ }
215
+ if (isInteractive() && ['frontend', 'full'].includes(options.preset) && options.withFrontend !== false) {
216
+ options.slot = await prompt('Initial frontend slot', options.slot);
217
+ }
218
+ if (!options.packageManager && isInteractive() && options.withFrontend !== false && options.preset !== 'minimal' && options.preset !== 'backend') {
219
+ options.packageManager = await select('Package manager', [
220
+ { value: 'npm' },
221
+ { value: 'pnpm' },
222
+ { value: 'yarn' },
223
+ { value: 'bun' },
224
+ ], 'npm');
225
+ }
226
+ if (options.createEnv === null && isInteractive()) {
227
+ options.createEnv = await confirm('Create .env from .env.example?', false);
228
+ }
229
+ if (options.install === null && isInteractive() && options.withFrontend !== false && options.preset !== 'minimal' && options.preset !== 'backend') {
230
+ options.install = await confirm('Install frontend dependencies now?', false);
231
+ }
232
+
233
+ return applyPreset(options);
234
+ }
235
+
236
+ function studly(value) {
237
+ return value
238
+ .split('-')
239
+ .filter(Boolean)
240
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
241
+ .join('');
242
+ }
243
+
244
+ function displayName(value) {
245
+ return value
246
+ .split('-')
247
+ .filter(Boolean)
248
+ .map(part => part.charAt(0).toUpperCase() + part.slice(1))
249
+ .join(' ');
250
+ }
251
+
252
+ function libraryName(id) {
253
+ return id
254
+ .split(/[\/-]/)
255
+ .filter(Boolean)
256
+ .map(studly)
257
+ .join('');
258
+ }
259
+
260
+ function yamlString(value) {
261
+ return String(value).replace(/\\/g, '\\\\').replace(/"/g, '\\"');
262
+ }
263
+
264
+ function writeFile(filePath, content) {
265
+ fs.mkdirSync(path.dirname(filePath), { recursive: true });
266
+ fs.writeFileSync(filePath, content);
267
+ console.log(` created ${path.relative(process.cwd(), filePath)}`);
268
+ }
269
+
270
+ function ensureTarget(target, force) {
271
+ if (!fs.existsSync(target)) {
272
+ fs.mkdirSync(target, { recursive: true });
273
+ return;
274
+ }
275
+
276
+ const entries = fs.readdirSync(target);
277
+ if (entries.length > 0 || !force) {
278
+ console.error(`Error: target directory already exists: ${target}`);
279
+ console.error('Use --force only for an existing empty directory.');
280
+ process.exit(1);
281
+ }
282
+ }
283
+
284
+ function manifestTemplate({ id, vendor, name, className, namespace, frontend, apiRoutes, display, description }) {
285
+ const frontendSection = frontend
286
+ ? `
287
+ frontend:
288
+ bundle: "resources/frontend/dist/extension.js"
289
+ `
290
+ : '';
291
+ const backendSection = apiRoutes
292
+ ? `
293
+ backend:
294
+ routes:
295
+ api-client: "src/routes/api-client.php"
296
+ `
297
+ : '';
298
+
299
+ return `notur: "1.0"
300
+ id: "${id}"
301
+ name: "${yamlString(display)}"
302
+ version: "1.0.0"
303
+ description: "${yamlString(description)}"
304
+ license: "MIT"
305
+
306
+ requires:
307
+ notur: "^1.0"
308
+ pterodactyl: "^1.12"
309
+ php: "^8.2"
310
+
311
+ entrypoint: "${namespace.replace(/\\/g, '\\\\')}\\\\${className}"
312
+ autoload:
313
+ psr-4:
314
+ "${namespace.replace(/\\/g, '\\\\')}\\\\": "src/"
315
+ ${backendSection}
316
+ ${frontendSection}`;
317
+ }
318
+
319
+ function phpTemplate({ namespace, className }) {
320
+ return `<?php
321
+
322
+ declare(strict_types=1);
323
+
324
+ namespace ${namespace};
325
+
326
+ use Notur\\Support\\NoturExtension;
327
+
328
+ class ${className} extends NoturExtension
329
+ {
330
+ }
331
+ `;
332
+ }
333
+
334
+ function apiRouteTemplate() {
335
+ return `<?php
336
+
337
+ use Illuminate\\Support\\Facades\\Route;
338
+
339
+ Route::get('/ping', function () {
340
+ return response()->json([
341
+ 'message' => 'pong',
342
+ ]);
343
+ });
344
+ `;
345
+ }
346
+
347
+ function frontendTemplate({ id, slot }) {
348
+ return `import * as React from 'react';
349
+ import { createExtension } from '@notur/sdk';
350
+
351
+ const ExampleButton: React.FC = () => {
352
+ return (
353
+ <button
354
+ style={{
355
+ background: '#dc2626',
356
+ color: '#fff',
357
+ border: 0,
358
+ borderRadius: '6px',
359
+ padding: '8px 12px',
360
+ fontWeight: 600,
361
+ cursor: 'pointer',
362
+ }}
363
+ onClick={() => alert('Hello from Notur')}
364
+ >
365
+ Red Button
366
+ </button>
367
+ );
368
+ };
369
+
370
+ createExtension({
371
+ id: '${id}',
372
+ slots: [
373
+ {
374
+ slot: '${slot}',
375
+ component: ExampleButton,
376
+ order: 10,
377
+ },
378
+ ],
379
+ });
380
+ `;
381
+ }
382
+
383
+ function packageTemplate(id) {
384
+ return `${JSON.stringify({
385
+ name: id.replace('/', '-'),
386
+ version: '1.0.0',
387
+ private: true,
388
+ scripts: {
389
+ build: 'webpack-cli --mode production --config webpack.config.js',
390
+ dev: 'webpack-cli --mode development --watch --config webpack.config.js',
391
+ pack: 'notur-pack',
392
+ push: 'notur-push',
393
+ },
394
+ peerDependencies: {
395
+ react: '^16.14.0',
396
+ 'react-dom': '^16.14.0',
397
+ },
398
+ devDependencies: {
399
+ '@notur/sdk': '^1.4.5',
400
+ '@types/react': '^16.14.0',
401
+ '@types/react-dom': '^16.9.0',
402
+ react: '^16.14.0',
403
+ 'react-dom': '^16.14.0',
404
+ 'ts-loader': '^9.5.0',
405
+ typescript: '^5.3.0',
406
+ webpack: '^5.90.0',
407
+ 'webpack-cli': '^6.0.0',
408
+ },
409
+ }, null, 2)}
410
+ `;
411
+ }
412
+
413
+ function tsconfigTemplate() {
414
+ return `{
415
+ "compilerOptions": {
416
+ "target": "ES2019",
417
+ "module": "ESNext",
418
+ "moduleResolution": "Node",
419
+ "jsx": "react",
420
+ "strict": true,
421
+ "esModuleInterop": true,
422
+ "skipLibCheck": true,
423
+ "forceConsistentCasingInFileNames": true
424
+ },
425
+ "include": ["resources/frontend/src/**/*"]
426
+ }
427
+ `;
428
+ }
429
+
430
+ function webpackTemplate(libName) {
431
+ return `const path = require('path');
432
+ const base = require('@notur/sdk/webpack.extension.config');
433
+
434
+ module.exports = {
435
+ ...base,
436
+ entry: './resources/frontend/src/index.tsx',
437
+ output: {
438
+ ...base.output,
439
+ filename: 'extension.js',
440
+ path: path.resolve(__dirname, 'resources/frontend/dist'),
441
+ library: {
442
+ ...base.output.library,
443
+ name: '__NOTUR_EXT_${libName}__',
444
+ type: 'umd',
445
+ },
446
+ },
447
+ };
448
+ `;
449
+ }
450
+
451
+ function readmeTemplate(id) {
452
+ return `# ${id}
453
+
454
+ Development:
455
+
456
+ \`\`\`bash
457
+ npm install
458
+ npm run build
459
+ npx notur-pack
460
+ \`\`\`
461
+
462
+ Remote push to a Notur-enabled panel:
463
+
464
+ \`\`\`bash
465
+ cp .env.example .env
466
+ npm run push
467
+ \`\`\`
468
+
469
+ Or pass values directly:
470
+
471
+ \`\`\`bash
472
+ npx notur-push --host https://panel.example.com --key notur_xxx
473
+ \`\`\`
474
+ `;
475
+ }
476
+
477
+ function installCommand(packageManager) {
478
+ if (packageManager === 'pnpm') return ['pnpm', ['install']];
479
+ if (packageManager === 'yarn') return ['yarn', ['install']];
480
+ if (packageManager === 'bun') return ['bun', ['install']];
481
+ return ['npm', ['install']];
482
+ }
483
+
484
+ function runScriptCommand(packageManager, script) {
485
+ if (packageManager === 'bun') return `bun run ${script}`;
486
+ if (packageManager === 'pnpm') return `pnpm run ${script}`;
487
+ if (packageManager === 'yarn') return `yarn ${script}`;
488
+ return `npm run ${script}`;
489
+ }
490
+
491
+ function runInstall(target, packageManager) {
492
+ const [command, args] = installCommand(packageManager || 'npm');
493
+ console.log(`\nRunning ${command} ${args.join(' ')}...`);
494
+ const result = spawnSync(command, args, {
495
+ cwd: target,
496
+ stdio: 'inherit',
497
+ });
498
+
499
+ if (result.status !== 0) {
500
+ console.warn(`Dependency install failed. Run ${command} ${args.join(' ')} manually in ${target}.`);
501
+ }
502
+ }
503
+
504
+ async function main() {
505
+ const options = await resolveOptions(parseArgs());
506
+ const [vendor, name] = options.id.split('/');
507
+ const namespace = `${studly(vendor)}\\${studly(name)}`;
508
+ const className = `${studly(name)}Extension`;
509
+ const target = path.resolve(options.path, name);
510
+
511
+ ensureTarget(target, options.force);
512
+
513
+ console.log(`Scaffolding ${options.id} in ${target}`);
514
+
515
+ writeFile(path.join(target, 'extension.yaml'), manifestTemplate({
516
+ id: options.id,
517
+ vendor,
518
+ name,
519
+ namespace,
520
+ className,
521
+ frontend: options.withFrontend,
522
+ apiRoutes: options.withApiRoutes,
523
+ display: options.displayName || displayName(name),
524
+ description: options.description || 'A Notur extension',
525
+ }));
526
+ writeFile(path.join(target, 'src', `${className}.php`), phpTemplate({ namespace, className }));
527
+ if (options.withApiRoutes) {
528
+ writeFile(path.join(target, 'src/routes/api-client.php'), apiRouteTemplate());
529
+ }
530
+ writeFile(path.join(target, 'README.md'), readmeTemplate(options.id));
531
+ writeFile(path.join(target, '.env.example'), `NOTUR_HOST=https://panel.example.com
532
+ NOTUR_PUSH_KEY=notur_xxx
533
+ `);
534
+ if (options.createEnv) {
535
+ writeFile(path.join(target, '.env'), `NOTUR_HOST=https://panel.example.com
536
+ NOTUR_PUSH_KEY=notur_xxx
537
+ `);
538
+ }
539
+ writeFile(path.join(target, '.gitignore'), `node_modules/
540
+ vendor/
541
+ resources/frontend/dist/
542
+ .env
543
+ *.notur
544
+ *.notur.sha256
545
+ *.notur.sig
546
+ `);
547
+
548
+ if (options.withFrontend) {
549
+ writeFile(path.join(target, 'resources/frontend/src/index.tsx'), frontendTemplate({
550
+ id: options.id,
551
+ slot: options.slot,
552
+ }));
553
+ writeFile(path.join(target, 'package.json'), packageTemplate(options.id));
554
+ writeFile(path.join(target, 'tsconfig.json'), tsconfigTemplate());
555
+ writeFile(path.join(target, 'webpack.config.js'), webpackTemplate(libraryName(options.id)));
556
+ }
557
+
558
+ if (options.install && options.withFrontend) {
559
+ runInstall(target, options.packageManager);
560
+ }
561
+
562
+ console.log('\nNext steps:');
563
+ if (options.withFrontend) {
564
+ console.log(` cd ${target}`);
565
+ if (!options.install) {
566
+ const [command, args] = installCommand(options.packageManager || 'npm');
567
+ console.log(` ${command} ${args.join(' ')}`);
568
+ }
569
+ console.log(` ${runScriptCommand(options.packageManager || 'npm', 'build')}`);
570
+ console.log(' npx notur-pack');
571
+ } else {
572
+ console.log(` cd ${target}`);
573
+ console.log(' npx notur-pack');
574
+ }
575
+ }
576
+
577
+ main().catch(error => {
578
+ console.error(error?.message || String(error));
579
+ process.exit(1);
580
+ });
@@ -0,0 +1,331 @@
1
+ #!/usr/bin/env node
2
+
3
+ const fs = require('fs');
4
+ const os = require('os');
5
+ const path = require('path');
6
+ const { spawnSync } = require('child_process');
7
+
8
+ function parseArgs() {
9
+ const args = process.argv.slice(2);
10
+ const options = {
11
+ path: '.',
12
+ archive: null,
13
+ host: null,
14
+ key: null,
15
+ envFile: null,
16
+ endpoint: '/api/notur/dev/push',
17
+ force: true,
18
+ noBuild: false,
19
+ keepArchive: false,
20
+ };
21
+
22
+ for (let i = 0; i < args.length; i++) {
23
+ const arg = args[i];
24
+ if (arg === '--archive') {
25
+ options.archive = args[++i];
26
+ } else if (arg === '--host') {
27
+ options.host = args[++i];
28
+ } else if (arg === '--key') {
29
+ options.key = args[++i];
30
+ } else if (arg === '--env-file') {
31
+ options.envFile = args[++i];
32
+ } else if (arg === '--endpoint') {
33
+ options.endpoint = args[++i];
34
+ } else if (arg === '--no-force') {
35
+ options.force = false;
36
+ } else if (arg === '--no-build') {
37
+ options.noBuild = true;
38
+ } else if (arg === '--keep-archive') {
39
+ options.keepArchive = true;
40
+ } else if (arg === '--help' || arg === '-h') {
41
+ usage(0);
42
+ } else if (!arg.startsWith('-')) {
43
+ options.path = arg;
44
+ } else {
45
+ console.error(`Unknown argument: ${arg}`);
46
+ usage(1);
47
+ }
48
+ }
49
+
50
+ return options;
51
+ }
52
+
53
+ function usage(code) {
54
+ console.log(`Usage:
55
+ npx notur-push [path] --host https://panel.example.com --key notur_xxx
56
+ npx @notur/sdk push [path] --host https://panel.example.com --key notur_xxx
57
+
58
+ Options:
59
+ --archive <file> Upload an existing .notur archive instead of packing
60
+ --host <url> Remote Pterodactyl panel URL
61
+ --key <token> Notur remote push token
62
+ --env-file <file> Load values from a custom env file
63
+ --endpoint <path> Remote push endpoint (default: /api/notur/dev/push)
64
+ --no-build Skip npm/yarn/pnpm/bun build before packing
65
+ --no-force Do not overwrite an already installed extension
66
+ --keep-archive Keep the temporary archive generated for this push`);
67
+ process.exit(code);
68
+ }
69
+
70
+ function loadManifest(dir) {
71
+ const manifestPath = path.join(dir, 'extension.yaml');
72
+ if (!fs.existsSync(manifestPath)) {
73
+ return { id: path.basename(dir), version: 'dev' };
74
+ }
75
+
76
+ const raw = fs.readFileSync(manifestPath, 'utf8');
77
+ const id = raw.match(/^id:\s*["']?([^"'\n]+)["']?/m)?.[1]?.trim();
78
+ const version = raw.match(/^version:\s*["']?([^"'\n]+)["']?/m)?.[1]?.trim();
79
+
80
+ return {
81
+ id: id || path.basename(dir),
82
+ version: version || 'dev',
83
+ };
84
+ }
85
+
86
+ function detectPackageManager(dir) {
87
+ if (fs.existsSync(path.join(dir, 'bun.lockb')) || fs.existsSync(path.join(dir, 'bun.lock'))) return 'bun';
88
+ if (fs.existsSync(path.join(dir, 'pnpm-lock.yaml'))) return 'pnpm';
89
+ if (fs.existsSync(path.join(dir, 'yarn.lock'))) return 'yarn';
90
+ if (fs.existsSync(path.join(dir, 'package-lock.json')) || fs.existsSync(path.join(dir, 'package.json'))) return 'npm';
91
+ return null;
92
+ }
93
+
94
+ function buildCommand(packageManager) {
95
+ switch (packageManager) {
96
+ case 'bun':
97
+ return ['bun', ['run', 'build']];
98
+ case 'pnpm':
99
+ return ['pnpm', ['run', 'build']];
100
+ case 'yarn':
101
+ return ['yarn', ['run', 'build']];
102
+ case 'npm':
103
+ return ['npm', ['run', 'build']];
104
+ default:
105
+ return null;
106
+ }
107
+ }
108
+
109
+ function maybeBuild(dir, noBuild) {
110
+ if (noBuild || !fs.existsSync(path.join(dir, 'package.json'))) {
111
+ return;
112
+ }
113
+
114
+ const packageJson = JSON.parse(fs.readFileSync(path.join(dir, 'package.json'), 'utf8'));
115
+ if (!packageJson.scripts || !packageJson.scripts.build) {
116
+ return;
117
+ }
118
+
119
+ const packageManager = detectPackageManager(dir);
120
+ const command = buildCommand(packageManager);
121
+ if (!command) {
122
+ console.warn('No supported package manager found; skipping build.');
123
+ return;
124
+ }
125
+
126
+ console.log(`Running ${command[0]} ${command[1].join(' ')}...`);
127
+ const result = spawnSync(command[0], command[1], {
128
+ cwd: dir,
129
+ stdio: 'inherit',
130
+ });
131
+
132
+ if (result.status !== 0) {
133
+ console.error('Build failed; aborting push.');
134
+ process.exit(result.status ?? 1);
135
+ }
136
+ }
137
+
138
+ function packArchive(dir) {
139
+ const manifest = loadManifest(dir);
140
+ const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), 'notur-push-'));
141
+ const archiveName = `${manifest.id.replace('/', '-')}-${manifest.version}.notur`;
142
+ const archivePath = path.join(tmpDir, archiveName);
143
+ const packScript = path.join(__dirname, 'notur-pack.js');
144
+
145
+ const result = spawnSync(process.execPath, [packScript, dir, '--output', archivePath], {
146
+ stdio: 'inherit',
147
+ });
148
+
149
+ if (result.status !== 0) {
150
+ console.error('Packaging failed; aborting push.');
151
+ process.exit(result.status ?? 1);
152
+ }
153
+
154
+ return { archivePath, tmpDir };
155
+ }
156
+
157
+ function resolveUrl(host, endpoint, force) {
158
+ const base = host.replace(/\/+$/, '');
159
+ const pathPart = endpoint.startsWith('/') ? endpoint : `/${endpoint}`;
160
+ const url = new URL(`${base}${pathPart}`);
161
+ if (!force) {
162
+ url.searchParams.set('force', '0');
163
+ }
164
+ return url;
165
+ }
166
+
167
+ async function pushArchive(options, archivePath) {
168
+ if (typeof fetch !== 'function' || typeof FormData !== 'function' || typeof Blob !== 'function') {
169
+ console.error('Error: notur-push requires Node.js 18+ for fetch/FormData support.');
170
+ process.exit(1);
171
+ }
172
+
173
+ const url = resolveUrl(options.host, options.endpoint, options.force);
174
+ const data = fs.readFileSync(archivePath);
175
+ const form = new FormData();
176
+ form.append('extension', new Blob([data]), path.basename(archivePath));
177
+
178
+ const signaturePath = `${archivePath}.sig`;
179
+ if (fs.existsSync(signaturePath)) {
180
+ const signature = fs.readFileSync(signaturePath);
181
+ form.append('signature', new Blob([signature]), path.basename(signaturePath));
182
+ }
183
+
184
+ console.log(`Uploading ${path.basename(archivePath)} to ${url.origin}...`);
185
+
186
+ const response = await fetch(url, {
187
+ method: 'POST',
188
+ headers: {
189
+ Authorization: `Bearer ${options.key}`,
190
+ Accept: 'application/json',
191
+ },
192
+ body: form,
193
+ });
194
+
195
+ const text = await response.text();
196
+ let payload = null;
197
+ try {
198
+ payload = text ? JSON.parse(text) : null;
199
+ } catch {
200
+ payload = null;
201
+ }
202
+
203
+ if (!response.ok) {
204
+ console.error(`Remote push failed (${response.status}).`);
205
+ if (payload?.message) {
206
+ console.error(payload.message);
207
+ } else if (text) {
208
+ console.error(text);
209
+ }
210
+ process.exit(1);
211
+ }
212
+
213
+ if (payload) {
214
+ console.log(`Pushed ${payload.id || 'extension'} v${payload.version || 'unknown'}.`);
215
+ if (payload.output) {
216
+ console.log(payload.output.trim());
217
+ }
218
+ } else {
219
+ console.log('Push completed.');
220
+ }
221
+ }
222
+
223
+ function parseEnvValue(value) {
224
+ let parsed = value.trim();
225
+ if (
226
+ (parsed.startsWith('"') && parsed.endsWith('"')) ||
227
+ (parsed.startsWith("'") && parsed.endsWith("'"))
228
+ ) {
229
+ parsed = parsed.slice(1, -1);
230
+ }
231
+ return parsed;
232
+ }
233
+
234
+ function loadEnvFile(filePath) {
235
+ if (!fs.existsSync(filePath)) {
236
+ return {};
237
+ }
238
+
239
+ const values = {};
240
+ const lines = fs.readFileSync(filePath, 'utf8').split(/\r?\n/);
241
+
242
+ for (const line of lines) {
243
+ const trimmed = line.trim();
244
+ if (!trimmed || trimmed.startsWith('#')) {
245
+ continue;
246
+ }
247
+
248
+ const match = trimmed.match(/^(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)=(.*)$/);
249
+ if (!match) {
250
+ continue;
251
+ }
252
+
253
+ values[match[1]] = parseEnvValue(match[2]);
254
+ }
255
+
256
+ return values;
257
+ }
258
+
259
+ function resolvePushConfig(options, extensionPath) {
260
+ const envFile = options.envFile
261
+ ? path.resolve(options.envFile)
262
+ : path.join(extensionPath, '.env');
263
+ const fileEnv = loadEnvFile(envFile);
264
+
265
+ return {
266
+ ...options,
267
+ host: options.host || process.env.NOTUR_HOST || fileEnv.NOTUR_HOST || null,
268
+ key:
269
+ options.key ||
270
+ process.env.NOTUR_API_KEY ||
271
+ process.env.NOTUR_PUSH_KEY ||
272
+ fileEnv.NOTUR_API_KEY ||
273
+ fileEnv.NOTUR_PUSH_KEY ||
274
+ null,
275
+ endpoint: options.endpoint || fileEnv.NOTUR_ENDPOINT || '/api/notur/dev/push',
276
+ };
277
+ }
278
+
279
+ async function main() {
280
+ let options = parseArgs();
281
+ const extensionPath = path.resolve(options.path);
282
+
283
+ if (!fs.existsSync(extensionPath) || !fs.statSync(extensionPath).isDirectory()) {
284
+ console.error(`Error: extension path does not exist: ${extensionPath}`);
285
+ process.exit(1);
286
+ }
287
+
288
+ options = resolvePushConfig(options, extensionPath);
289
+
290
+ if (!options.host) {
291
+ console.error('Error: --host is required, or set NOTUR_HOST in the environment or local .env.');
292
+ process.exit(1);
293
+ }
294
+
295
+ if (!options.key) {
296
+ console.error('Error: --key is required, or set NOTUR_PUSH_KEY / NOTUR_API_KEY in the environment or local .env.');
297
+ process.exit(1);
298
+ }
299
+
300
+ if (!options.archive) {
301
+ maybeBuild(extensionPath, options.noBuild);
302
+ }
303
+
304
+ let archivePath = options.archive ? path.resolve(options.archive) : null;
305
+ let tmpDir = null;
306
+ if (!archivePath) {
307
+ const packed = packArchive(extensionPath);
308
+ archivePath = packed.archivePath;
309
+ tmpDir = packed.tmpDir;
310
+ }
311
+
312
+ if (!fs.existsSync(archivePath)) {
313
+ console.error(`Error: archive does not exist: ${archivePath}`);
314
+ process.exit(1);
315
+ }
316
+
317
+ try {
318
+ await pushArchive(options, archivePath);
319
+ } finally {
320
+ if (tmpDir && !options.keepArchive) {
321
+ fs.rmSync(tmpDir, { recursive: true, force: true });
322
+ } else if (tmpDir) {
323
+ console.log(`Kept archive at ${archivePath}`);
324
+ }
325
+ }
326
+ }
327
+
328
+ main().catch(error => {
329
+ console.error(error?.message || String(error));
330
+ process.exit(1);
331
+ });
package/bin/notur.js ADDED
@@ -0,0 +1,40 @@
1
+ #!/usr/bin/env node
2
+
3
+ const path = require('path');
4
+ const { spawnSync } = require('child_process');
5
+
6
+ const commands = {
7
+ create: 'notur-create.js',
8
+ pack: 'notur-pack.js',
9
+ keygen: 'notur-keygen.js',
10
+ push: 'notur-push.js',
11
+ };
12
+
13
+ const [command, ...args] = process.argv.slice(2);
14
+
15
+ if (!command || command === '--help' || command === '-h') {
16
+ console.log(`Notur SDK CLI
17
+
18
+ Usage:
19
+ notur create <vendor/name> [options]
20
+ notur pack [path] [options]
21
+ notur push [path] --host <url> --key <token>
22
+ notur keygen
23
+
24
+ Standalone bins are also available:
25
+ notur-create, notur-pack, notur-push, notur-keygen`);
26
+ process.exit(command ? 0 : 1);
27
+ }
28
+
29
+ const script = commands[command];
30
+ if (!script) {
31
+ console.error(`Unknown command: ${command}`);
32
+ console.error('Run "notur --help" for usage.');
33
+ process.exit(1);
34
+ }
35
+
36
+ const result = spawnSync(process.execPath, [path.join(__dirname, script), ...args], {
37
+ stdio: 'inherit',
38
+ });
39
+
40
+ process.exit(result.status ?? 1);
@@ -0,0 +1,2 @@
1
+ NOTUR_HOST=https://panel.example.com
2
+ NOTUR_PUSH_KEY=notur_xxx
@@ -0,0 +1,30 @@
1
+ # Red Button Example
2
+
3
+ A minimal Notur frontend extension that renders a red button in the server header.
4
+
5
+ ## Local Build
6
+
7
+ ```bash
8
+ npm install
9
+ npm run build
10
+ ```
11
+
12
+ ## Package
13
+
14
+ ```bash
15
+ npx notur-pack
16
+ ```
17
+
18
+ ## Push To A Remote Panel
19
+
20
+ Create `.env` from `.env.example`, then run:
21
+
22
+ ```bash
23
+ npm run push
24
+ ```
25
+
26
+ The remote panel must have Notur remote push enabled:
27
+
28
+ ```bash
29
+ php artisan notur:remote-key
30
+ ```
@@ -0,0 +1,19 @@
1
+ notur: "1.0"
2
+ id: "acme/red-button"
3
+ name: "Red Button"
4
+ version: "1.0.0"
5
+ description: "Adds a red button to the server header"
6
+ license: "MIT"
7
+
8
+ requires:
9
+ notur: "^1.0"
10
+ pterodactyl: "^1.12"
11
+ php: "^8.2"
12
+
13
+ entrypoint: "Acme\\RedButton\\RedButtonExtension"
14
+ autoload:
15
+ psr-4:
16
+ "Acme\\RedButton\\": "src/"
17
+
18
+ frontend:
19
+ bundle: "resources/frontend/dist/extension.js"
@@ -0,0 +1,26 @@
1
+ {
2
+ "name": "acme-red-button",
3
+ "version": "1.0.0",
4
+ "private": true,
5
+ "scripts": {
6
+ "build": "webpack-cli --mode production --config webpack.config.js",
7
+ "dev": "webpack-cli --mode development --watch --config webpack.config.js",
8
+ "pack": "notur-pack",
9
+ "push": "notur-push"
10
+ },
11
+ "peerDependencies": {
12
+ "react": "^16.14.0",
13
+ "react-dom": "^16.14.0"
14
+ },
15
+ "devDependencies": {
16
+ "@notur/sdk": "^1.4.5",
17
+ "@types/react": "^16.14.0",
18
+ "@types/react-dom": "^16.9.0",
19
+ "react": "^16.14.0",
20
+ "react-dom": "^16.14.0",
21
+ "ts-loader": "^9.5.0",
22
+ "typescript": "^5.3.0",
23
+ "webpack": "^5.90.0",
24
+ "webpack-cli": "^6.0.0"
25
+ }
26
+ }
@@ -0,0 +1,32 @@
1
+ import * as React from 'react';
2
+ import { createExtension } from '@notur/sdk';
3
+
4
+ const RedButton: React.FC = () => {
5
+ return (
6
+ <button
7
+ style={{
8
+ background: '#dc2626',
9
+ color: '#fff',
10
+ border: 0,
11
+ borderRadius: '6px',
12
+ padding: '8px 12px',
13
+ fontWeight: 600,
14
+ cursor: 'pointer',
15
+ }}
16
+ onClick={() => alert('Hello from Notur')}
17
+ >
18
+ Red Button
19
+ </button>
20
+ );
21
+ };
22
+
23
+ createExtension({
24
+ id: 'acme/red-button',
25
+ slots: [
26
+ {
27
+ slot: 'server.header',
28
+ component: RedButton,
29
+ order: 10,
30
+ },
31
+ ],
32
+ });
@@ -0,0 +1,11 @@
1
+ <?php
2
+
3
+ declare(strict_types=1);
4
+
5
+ namespace Acme\RedButton;
6
+
7
+ use Notur\Support\NoturExtension;
8
+
9
+ class RedButtonExtension extends NoturExtension
10
+ {
11
+ }
@@ -0,0 +1,13 @@
1
+ {
2
+ "compilerOptions": {
3
+ "target": "ES2019",
4
+ "module": "ESNext",
5
+ "moduleResolution": "Node",
6
+ "jsx": "react",
7
+ "strict": true,
8
+ "esModuleInterop": true,
9
+ "skipLibCheck": true,
10
+ "forceConsistentCasingInFileNames": true
11
+ },
12
+ "include": ["resources/frontend/src/**/*"]
13
+ }
@@ -0,0 +1,17 @@
1
+ const path = require('path');
2
+ const base = require('@notur/sdk/webpack.extension.config');
3
+
4
+ module.exports = {
5
+ ...base,
6
+ entry: './resources/frontend/src/index.tsx',
7
+ output: {
8
+ ...base.output,
9
+ filename: 'extension.js',
10
+ path: path.resolve(__dirname, 'resources/frontend/dist'),
11
+ library: {
12
+ ...base.output.library,
13
+ name: '__NOTUR_EXT_AcmeRedButton__',
14
+ type: 'umd',
15
+ },
16
+ },
17
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@notur/sdk",
3
- "version": "1.4.4",
3
+ "version": "1.4.6",
4
4
  "description": "Notur Extension Developer SDK",
5
5
  "main": "dist/index.js",
6
6
  "module": "dist/index.js",
@@ -15,8 +15,12 @@
15
15
  "access": "public"
16
16
  },
17
17
  "bin": {
18
+ "sdk": "./bin/notur.js",
19
+ "notur": "./bin/notur.js",
20
+ "notur-create": "./bin/notur-create.js",
18
21
  "notur-pack": "./bin/notur-pack.js",
19
- "notur-keygen": "./bin/notur-keygen.js"
22
+ "notur-keygen": "./bin/notur-keygen.js",
23
+ "notur-push": "./bin/notur-push.js"
20
24
  },
21
25
  "keywords": [
22
26
  "notur",
@@ -29,7 +33,8 @@
29
33
  "dist/",
30
34
  "bin/",
31
35
  "webpack.extension.config.js",
32
- "templates/"
36
+ "templates/",
37
+ "examples/"
33
38
  ],
34
39
  "scripts": {
35
40
  "build": "tsc",