tsoft-cli 3.14.4 → 3.15.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +13 -0
- package/README.md +42 -0
- package/locales/en.json +30 -1
- package/locales/tr.json +30 -1
- package/package.json +1 -1
- package/src/app-package.js +90 -0
- package/src/commands/app-deploy.js +157 -0
- package/src/commands/login.js +15 -2
- package/src/commands/theme-push.js +72 -7
- package/src/config.js +3 -0
- package/src/errors/error-mapper.js +29 -1
- package/src/index.js +14 -0
- package/src/port-owner.js +71 -0
- package/src/theme-list.js +44 -0
- package/src/ui/help-renderer.js +6 -0
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,19 @@ All notable changes to T-Soft CLI will be documented in this file.
|
|
|
5
5
|
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
|
|
6
6
|
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
|
7
7
|
|
|
8
|
+
## [Unreleased]
|
|
9
|
+
|
|
10
|
+
## [3.15.0] - 2026-10-31
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- `tsoft app deploy [dirs...]` uploads storefront embed versions without the partner panel. Publishing an embed meant zipping the package by hand and uploading it in the browser, which a pipeline cannot do. The command reads `prefix` and `version` from each package's `package.json`, zips the same files the panel accepts (`legacy/` copies are left out) and uploads them with an organization deploy token from `TSOFT_DEPLOY_TOKEN` or `--token`; no `tsoft login` is needed. Each package is reported as live, in review or skipped: first-party apps with nothing to review go live at once, other versions wait for T-Soft approval, and a version number that is already live or in review is skipped without failing the run. Several directories can be given in one run; the command carries on after a refused package and exits with code 1 at the end. `--json` prints one document with every result.
|
|
14
|
+
|
|
15
|
+
### Fixed
|
|
16
|
+
- `theme push` now says which authorization failed when the store cannot renew its link to the theme store. The push carries a separate partner authorization alongside your CLI session; when the store could not renew that one it answered with `PUSH-AUTH-REFRESH-FAILED`, which the CLI did not know, so it repeated the store's text and left it unclear whether your CLI session had ended. The message now says the theme store (partner) authorization could not be renewed while your CLI session is still valid, and points at `tsoft login` to reconnect. With `--json` the code is returned under `error.code`.
|
|
17
|
+
- `theme push <name>` now pushes the theme with that name. The argument was sent to the store as given, but the store only knows a theme by its folder or alias, so pushing by the name `theme list` shows ended in `Request failed with status code 410`. A name is now looked up in the same theme list `theme list` reads and sent as the theme's folder; when two themes share the name, or none has it, the push stops before contacting the store and says so, listing the folders to choose from. A folder, an alias, a folder uuid and a push with no argument behave exactly as before, and if the theme list cannot be read the argument is sent as typed.
|
|
18
|
+
- A push to a theme the store does not have now reads "Theme not found: <theme>" with a pointer to `tsoft theme list`, instead of `Request failed with status code 410`. A 404 or 410 from the push is reported this way even when the store sends no error code, and the store's own message is kept next to it.
|
|
19
|
+
- `tsoft login` now names the application that holds the sign-in port. When the port is taken the message shows the process name and pid and the command to stop it (`kill` on macOS and Linux, `taskkill` on Windows); the CLI never stops it on its own. When the owner cannot be found the message is the same as before.
|
|
20
|
+
|
|
8
21
|
## [3.14.4] - 2026-10-26
|
|
9
22
|
|
|
10
23
|
### Fixed
|
package/README.md
CHANGED
|
@@ -115,6 +115,14 @@ Available on every command:
|
|
|
115
115
|
| `tsoft theme section create` | Create an empty section in the active theme |
|
|
116
116
|
| `tsoft theme section fork` | Fork an existing section into the active theme |
|
|
117
117
|
|
|
118
|
+
### Apps
|
|
119
|
+
|
|
120
|
+
| Command | Description |
|
|
121
|
+
| -------------------------------- | -------------------------------------------------------- |
|
|
122
|
+
| `tsoft app deploy [dirs...]` | Upload storefront embed packages (no sign-in, for CI) |
|
|
123
|
+
|
|
124
|
+
See [Deploying Storefront Embeds](#deploying-storefront-embeds).
|
|
125
|
+
|
|
118
126
|
## Developing a Theme
|
|
119
127
|
|
|
120
128
|
```bash
|
|
@@ -208,6 +216,40 @@ Combine with `--store` to target a specific store from a pipeline:
|
|
|
208
216
|
tsoft theme push my-theme --store example.com --json
|
|
209
217
|
```
|
|
210
218
|
|
|
219
|
+
## Deploying Storefront Embeds
|
|
220
|
+
|
|
221
|
+
`tsoft app deploy` uploads an app's storefront embed version the same way the partner panel's zip upload does, without a browser or a store sign-in. Point it at one or more package directories in the installer layout:
|
|
222
|
+
|
|
223
|
+
```
|
|
224
|
+
my-app/
|
|
225
|
+
├── package.json # prefix and version (e.g. "1.2.0")
|
|
226
|
+
├── input.json # settings form
|
|
227
|
+
├── modules/trackingcode/*.html # loader templates
|
|
228
|
+
└── embed/v1/*.js|css # scripts for major 1
|
|
229
|
+
```
|
|
230
|
+
|
|
231
|
+
Only these files are uploaded; `legacy/` template copies and anything else in the directory stay local.
|
|
232
|
+
|
|
233
|
+
```bash
|
|
234
|
+
# The token is an organization deploy token, read from the environment...
|
|
235
|
+
export TSOFT_DEPLOY_TOKEN=tsd_...
|
|
236
|
+
tsoft app deploy stores/apps/dev.64/my-app
|
|
237
|
+
|
|
238
|
+
# ...or given on the command line. Several packages can go in one run.
|
|
239
|
+
tsoft app deploy stores/apps/dev.64/a stores/apps/dev.64/b --token tsd_...
|
|
240
|
+
```
|
|
241
|
+
|
|
242
|
+
Each package ends up in one of these states:
|
|
243
|
+
|
|
244
|
+
| Result | Meaning |
|
|
245
|
+
| ----------- | -------------------------------------------------------------------------------------------- |
|
|
246
|
+
| Live | First-party app with nothing to review: the scripts are already served to stores. |
|
|
247
|
+
| In review | Waits for T-Soft approval in the review queue (partner apps, or a new script host). |
|
|
248
|
+
| Skipped | That version number is already live or in review; nothing is uploaded, the run does not fail. |
|
|
249
|
+
| Failed | The package was refused (the reason is printed); the run exits with code 1. |
|
|
250
|
+
|
|
251
|
+
A changed script needs a new version in `package.json`; the same number is skipped. Removing a setting or changing a field's type needs a new major version. With `--json` the command prints one document with a `results` array. `TSOFT_APPS_API_URL` points the command at another API (default `https://api.tsoftapps.com`).
|
|
252
|
+
|
|
211
253
|
## Security
|
|
212
254
|
|
|
213
255
|
- **OAuth2 PKCE**: Authorization as a public client, no client secret on disk
|
package/locales/en.json
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"error.push.payload_too_large.message_unknown_size": "Theme package exceeds the server limit.",
|
|
27
27
|
"error.push.payload_too_large.hint": "Compress large images or remove them from the theme.",
|
|
28
28
|
"error.push.payload_too_large.largest_files_title": "Largest files:",
|
|
29
|
+
"error.push.auth_refresh_failed.message": "The theme store (partner) authorization could not be renewed; your CLI session is still valid.",
|
|
30
|
+
"error.push.auth_refresh_failed.hint": "Reconnect with: tsoft login",
|
|
31
|
+
"error.push.theme_not_found.message": "Theme not found: {{theme}}",
|
|
32
|
+
"error.push.theme_not_found.message_with_detail": "Theme not found: {{theme}} ({{detail}})",
|
|
33
|
+
"error.push.theme_not_found.hint": "See the themes in this store: tsoft theme list",
|
|
29
34
|
"error.http.401.message": "Authorization error.",
|
|
30
35
|
"error.http.401.hint": "Login again: tsoft login --org",
|
|
31
36
|
"error.http.403.message": "You do not have permission for this operation.",
|
|
@@ -61,6 +66,8 @@
|
|
|
61
66
|
"login.port_in_use.message": "Port {{port}}, which sign-in needs, is already in use by another application.",
|
|
62
67
|
"login.port_in_use.close_hint": "If \"tsoft login\" is still running in another terminal, stop it there with Ctrl+C and try again.",
|
|
63
68
|
"login.port_in_use.find_hint": "To see which application is using the port, run: {{command}}",
|
|
69
|
+
"login.port_in_use.owner": "It is held by {{command}} (pid {{pid}}).",
|
|
70
|
+
"login.port_in_use.stop_hint": "If you do not need that application, stop it with: {{command}}",
|
|
64
71
|
"login.server_start_failed.message": "Sign-in could not start: the local connection it needs could not be opened on this computer.",
|
|
65
72
|
"login.server_start_failed.hint": "For details: tsoft login --verbose",
|
|
66
73
|
"login.security_error": "State parameter mismatch! This may indicate a security issue.",
|
|
@@ -146,6 +153,8 @@
|
|
|
146
153
|
"push.no_active_theme": "No active theme found.",
|
|
147
154
|
"push.usage": "Usage: tsoft theme push <theme-name>",
|
|
148
155
|
"push.use_tip": "Or set an active theme with \"tsoft theme use <theme-name>\".",
|
|
156
|
+
"push.name_ambiguous.message": "More than one theme is named \"{{name}}\". Push by folder instead:",
|
|
157
|
+
"push.name_ambiguous.hint": "tsoft theme push <folder>",
|
|
149
158
|
"push.connecting": "Connecting to server...",
|
|
150
159
|
"push.checking_status": "Checking current status...",
|
|
151
160
|
"push.pushing": "Pushing theme...",
|
|
@@ -406,5 +415,25 @@
|
|
|
406
415
|
"config.autoupgrade.status_on": "Version checking: on",
|
|
407
416
|
"config.autoupgrade.status_off": "Version checking: off",
|
|
408
417
|
"config.autoupgrade.usage": "Usage: tsoft config autoupgrade on | off | status",
|
|
409
|
-
"dev.no_active_store": "No active store.\n\nTo sign in: tsoft login"
|
|
418
|
+
"dev.no_active_store": "No active store.\n\nTo sign in: tsoft login",
|
|
419
|
+
"help.group.apps": "Apps",
|
|
420
|
+
"help.cmd.app_deploy": "Publish a storefront embed version (CI)",
|
|
421
|
+
"app.deploy.published": "✓ Live: {{label}}",
|
|
422
|
+
"app.deploy.pending_review": "◷ In review: {{label}} — goes live after T-Soft approves it",
|
|
423
|
+
"app.deploy.already_live": "↷ Skipped: {{label}} is already live",
|
|
424
|
+
"app.deploy.already_pending": "↷ Skipped: {{label}} is already in review",
|
|
425
|
+
"app.deploy.failed": "✗ Upload failed: {{label}}",
|
|
426
|
+
"app.deploy.summary": "{{total}} packages: {{published}} live, {{pending}} in review, {{skipped}} skipped, {{failed}} failed",
|
|
427
|
+
"app.deploy.error.no_token": "No deploy token found.",
|
|
428
|
+
"app.deploy.error.no_token_hint": "Set the TSOFT_DEPLOY_TOKEN environment variable or pass --token.",
|
|
429
|
+
"app.deploy.error.not_a_directory": "{{dir}} is not a directory.",
|
|
430
|
+
"app.deploy.error.no_manifest": "No package.json in {{dir}}.",
|
|
431
|
+
"app.deploy.error.bad_manifest": "Could not read {{dir}}/package.json (invalid JSON).",
|
|
432
|
+
"app.deploy.error.missing_fields": "{{dir}}/package.json must have prefix and version.",
|
|
433
|
+
"app.deploy.error.no_embed": "The package in {{dir}} has no embed module; only storefront embed packages are uploaded.",
|
|
434
|
+
"app.deploy.error.network": "Could not reach {{url}}: {{message}}",
|
|
435
|
+
"app.deploy.error.unauthorized": "The deploy token is invalid or revoked.",
|
|
436
|
+
"app.deploy.error.forbidden": "The deploy token may not upload versions.",
|
|
437
|
+
"app.deploy.error.app_not_found": "App {{prefix}} was not found in the token's organization.",
|
|
438
|
+
"app.deploy.error.http": "The server answered {{status}}."
|
|
410
439
|
}
|
package/locales/tr.json
CHANGED
|
@@ -26,6 +26,11 @@
|
|
|
26
26
|
"error.push.payload_too_large.message_unknown_size": "Tema paketi sunucu sınırını aşıyor.",
|
|
27
27
|
"error.push.payload_too_large.hint": "Büyük görselleri sıkıştırın ya da temadan çıkarın.",
|
|
28
28
|
"error.push.payload_too_large.largest_files_title": "En büyük dosyalar:",
|
|
29
|
+
"error.push.auth_refresh_failed.message": "Tema mağazası (partner) yetkisi yenilenemedi; CLI oturumunuz geçerli.",
|
|
30
|
+
"error.push.auth_refresh_failed.hint": "Yeniden bağlanmak için: tsoft login",
|
|
31
|
+
"error.push.theme_not_found.message": "Tema bulunamadı: {{theme}}",
|
|
32
|
+
"error.push.theme_not_found.message_with_detail": "Tema bulunamadı: {{theme}} ({{detail}})",
|
|
33
|
+
"error.push.theme_not_found.hint": "Bu mağazadaki temaları görmek için: tsoft theme list",
|
|
29
34
|
"error.http.401.message": "Yetkilendirme hatası.",
|
|
30
35
|
"error.http.401.hint": "Tekrar giriş yapın: tsoft login --org",
|
|
31
36
|
"error.http.403.message": "Bu işlem için yetkiniz yok.",
|
|
@@ -61,6 +66,8 @@
|
|
|
61
66
|
"login.port_in_use.message": "Giriş için gereken {{port}} numaralı port şu anda başka bir uygulama tarafından kullanılıyor.",
|
|
62
67
|
"login.port_in_use.close_hint": "Başka bir terminalde açık kalmış bir \"tsoft login\" varsa o pencerede Ctrl+C ile durdurup tekrar deneyin.",
|
|
63
68
|
"login.port_in_use.find_hint": "Portu hangi uygulamanın kullandığını görmek için şunu çalıştırın: {{command}}",
|
|
69
|
+
"login.port_in_use.owner": "Portu {{command}} (pid {{pid}}) kullanıyor.",
|
|
70
|
+
"login.port_in_use.stop_hint": "O uygulamaya ihtiyacınız yoksa şununla kapatın: {{command}}",
|
|
64
71
|
"login.server_start_failed.message": "Giriş başlatılamadı: giriş için gereken yerel bağlantı bu bilgisayarda açılamadı.",
|
|
65
72
|
"login.server_start_failed.hint": "Detaylar için: tsoft login --verbose",
|
|
66
73
|
"login.security_error": "State parametresi eşleşmiyor! Güvenlik hatası olabilir.",
|
|
@@ -146,6 +153,8 @@
|
|
|
146
153
|
"push.no_active_theme": "Aktif tema bulunamadı.",
|
|
147
154
|
"push.usage": "Kullanim: tsoft theme push <theme-name>",
|
|
148
155
|
"push.use_tip": "veya \"tsoft theme use <theme-name>\" ile aktif tema ayarlayın.",
|
|
156
|
+
"push.name_ambiguous.message": "\"{{name}}\" adını taşıyan birden çok tema var. Klasör adıyla push edin:",
|
|
157
|
+
"push.name_ambiguous.hint": "tsoft theme push <klasör>",
|
|
149
158
|
"push.connecting": "Sunucuya bağlanılıyor...",
|
|
150
159
|
"push.checking_status": "Mevcut durum kontrol ediliyor...",
|
|
151
160
|
"push.pushing": "Tema push ediliyor...",
|
|
@@ -406,5 +415,25 @@
|
|
|
406
415
|
"config.autoupgrade.status_on": "Sürüm kontrolü: açık",
|
|
407
416
|
"config.autoupgrade.status_off": "Sürüm kontrolü: kapalı",
|
|
408
417
|
"config.autoupgrade.usage": "Kullanım: tsoft config autoupgrade on | off | status",
|
|
409
|
-
"dev.no_active_store": "Aktif mağaza bulunamadı.\n\nGiriş yapmak için: tsoft login"
|
|
418
|
+
"dev.no_active_store": "Aktif mağaza bulunamadı.\n\nGiriş yapmak için: tsoft login",
|
|
419
|
+
"help.group.apps": "Uygulamalar",
|
|
420
|
+
"help.cmd.app_deploy": "Vitrin eklentisi sürümünü yayınlayın (CI)",
|
|
421
|
+
"app.deploy.published": "✓ Yayında: {{label}}",
|
|
422
|
+
"app.deploy.pending_review": "◷ İncelemede: {{label}} — T-Soft onayından sonra yayına çıkar",
|
|
423
|
+
"app.deploy.already_live": "↷ Atlandı: {{label}} zaten yayında",
|
|
424
|
+
"app.deploy.already_pending": "↷ Atlandı: {{label}} zaten incelemede",
|
|
425
|
+
"app.deploy.failed": "✗ Yüklenemedi: {{label}}",
|
|
426
|
+
"app.deploy.summary": "{{total}} paket: {{published}} yayında, {{pending}} incelemede, {{skipped}} atlandı, {{failed}} hata",
|
|
427
|
+
"app.deploy.error.no_token": "Deploy token'ı bulunamadı.",
|
|
428
|
+
"app.deploy.error.no_token_hint": "TSOFT_DEPLOY_TOKEN ortam değişkenini tanımlayın ya da --token verin.",
|
|
429
|
+
"app.deploy.error.not_a_directory": "{{dir}} bir klasör değil.",
|
|
430
|
+
"app.deploy.error.no_manifest": "{{dir}} içinde package.json yok.",
|
|
431
|
+
"app.deploy.error.bad_manifest": "{{dir}}/package.json okunamadı (geçersiz JSON).",
|
|
432
|
+
"app.deploy.error.missing_fields": "{{dir}}/package.json içinde prefix ve version olmalı.",
|
|
433
|
+
"app.deploy.error.no_embed": "{{dir}} paketinde embed modülü yok; yalnız vitrin eklentisi taşıyan paketler yüklenir.",
|
|
434
|
+
"app.deploy.error.network": "{{url}} adresine ulaşılamadı: {{message}}",
|
|
435
|
+
"app.deploy.error.unauthorized": "Deploy token'ı geçersiz ya da iptal edilmiş.",
|
|
436
|
+
"app.deploy.error.forbidden": "Deploy token'ının sürüm yükleme yetkisi yok.",
|
|
437
|
+
"app.deploy.error.app_not_found": "{{prefix}} uygulaması token'ın organizasyonunda bulunamadı.",
|
|
438
|
+
"app.deploy.error.http": "Sunucu {{status}} yanıtı verdi."
|
|
410
439
|
}
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { existsSync, readFileSync, readdirSync, statSync } from 'fs';
|
|
2
|
+
import path from 'path';
|
|
3
|
+
import AdmZip from 'adm-zip';
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Storefront embed package (installer layout): package.json, input.json,
|
|
7
|
+
* modules/trackingcode/*.html and embed/v<major>/*.js|css. Everything else —
|
|
8
|
+
* legacy/ template copies, README, sources — stays out of the upload, the same
|
|
9
|
+
* set the partner panel accepts.
|
|
10
|
+
*/
|
|
11
|
+
const ACCEPTED = [
|
|
12
|
+
/^package\.json$/,
|
|
13
|
+
/^input\.json$/,
|
|
14
|
+
/^modules\/trackingcode\/[A-Za-z0-9._-]+\.html$/,
|
|
15
|
+
/^embed\/v[0-9]+\/[A-Za-z0-9._-]+\.(js|css)$/,
|
|
16
|
+
];
|
|
17
|
+
|
|
18
|
+
export class AppPackageError extends Error {
|
|
19
|
+
constructor(key, vars = {}) {
|
|
20
|
+
super(key);
|
|
21
|
+
this.key = key;
|
|
22
|
+
this.vars = vars;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
function listFiles(root, dir = '') {
|
|
27
|
+
const files = [];
|
|
28
|
+
|
|
29
|
+
for (const entry of readdirSync(path.join(root, dir), { withFileTypes: true })) {
|
|
30
|
+
const relative = dir ? `${dir}/${entry.name}` : entry.name;
|
|
31
|
+
|
|
32
|
+
if (entry.isDirectory()) {
|
|
33
|
+
files.push(...listFiles(root, relative));
|
|
34
|
+
} else if (entry.isFile()) {
|
|
35
|
+
files.push(relative);
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
return files;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* @returns {{dir: string, prefix: string, version: string, files: string[]}}
|
|
44
|
+
*/
|
|
45
|
+
export function readAppPackage(dir) {
|
|
46
|
+
const root = path.resolve(dir);
|
|
47
|
+
const manifestPath = path.join(root, 'package.json');
|
|
48
|
+
|
|
49
|
+
if (!existsSync(root) || !statSync(root).isDirectory()) {
|
|
50
|
+
throw new AppPackageError('app.deploy.error.not_a_directory', { dir });
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
if (!existsSync(manifestPath)) {
|
|
54
|
+
throw new AppPackageError('app.deploy.error.no_manifest', { dir });
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
let manifest;
|
|
58
|
+
try {
|
|
59
|
+
manifest = JSON.parse(readFileSync(manifestPath, 'utf-8'));
|
|
60
|
+
} catch {
|
|
61
|
+
throw new AppPackageError('app.deploy.error.bad_manifest', { dir });
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const prefix = typeof manifest.prefix === 'string' ? manifest.prefix.trim() : '';
|
|
65
|
+
const version = typeof manifest.version === 'string' ? manifest.version.trim() : '';
|
|
66
|
+
|
|
67
|
+
if (!prefix || !version) {
|
|
68
|
+
throw new AppPackageError('app.deploy.error.missing_fields', { dir });
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
const modules = Array.isArray(manifest.modules) ? manifest.modules : [];
|
|
72
|
+
if (!modules.some((module) => module?.type === 'embed')) {
|
|
73
|
+
throw new AppPackageError('app.deploy.error.no_embed', { dir });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const files = listFiles(root).filter((file) => ACCEPTED.some((pattern) => pattern.test(file))).sort();
|
|
77
|
+
|
|
78
|
+
return { dir: root, prefix, version, files };
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/** Zip of the accepted files, laid out exactly like a partner panel upload. */
|
|
82
|
+
export function buildAppPackageZip(pkg) {
|
|
83
|
+
const zip = new AdmZip();
|
|
84
|
+
|
|
85
|
+
for (const file of pkg.files) {
|
|
86
|
+
zip.addFile(file, readFileSync(path.join(pkg.dir, file)));
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return zip.toBuffer();
|
|
90
|
+
}
|
|
@@ -0,0 +1,157 @@
|
|
|
1
|
+
import axios from 'axios';
|
|
2
|
+
import chalk from 'chalk';
|
|
3
|
+
import path from 'path';
|
|
4
|
+
import { APPS_API_URL } from '../config.js';
|
|
5
|
+
import { isJsonMode, jsonOut, verboseLog } from '../output-mode.js';
|
|
6
|
+
import { t } from '../i18n.js';
|
|
7
|
+
import { AppPackageError, readAppPackage, buildAppPackageZip } from '../app-package.js';
|
|
8
|
+
|
|
9
|
+
export const DEPLOY_TOKEN_ENV = 'TSOFT_DEPLOY_TOKEN';
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* Sends one package to the deploy endpoint and turns the answer into a result row.
|
|
13
|
+
* The server runs the partner panel's checks; a first-party version with nothing to
|
|
14
|
+
* review goes live at once, anything else waits in the admin review queue.
|
|
15
|
+
*/
|
|
16
|
+
async function deployOne(dir, token, post) {
|
|
17
|
+
const row = { dir, prefix: null, version: null, result: 'failed', status: null, warnings: [], error: null };
|
|
18
|
+
|
|
19
|
+
let pkg;
|
|
20
|
+
try {
|
|
21
|
+
pkg = readAppPackage(dir);
|
|
22
|
+
} catch (error) {
|
|
23
|
+
row.error = error instanceof AppPackageError ? t(error.key, error.vars) : error.message;
|
|
24
|
+
return row;
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
row.prefix = pkg.prefix;
|
|
28
|
+
row.version = pkg.version;
|
|
29
|
+
|
|
30
|
+
const form = new FormData();
|
|
31
|
+
form.append('version', pkg.version);
|
|
32
|
+
form.append('file', new Blob([buildAppPackageZip(pkg)], { type: 'application/zip' }), `${path.basename(pkg.dir)}-${pkg.version}.zip`);
|
|
33
|
+
|
|
34
|
+
let response;
|
|
35
|
+
try {
|
|
36
|
+
response = await post(`${APPS_API_URL}/api/v3/deploy/apps/${encodeURIComponent(pkg.prefix)}/embed-versions`, form, {
|
|
37
|
+
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
|
|
38
|
+
maxBodyLength: Infinity,
|
|
39
|
+
validateStatus: () => true,
|
|
40
|
+
});
|
|
41
|
+
} catch (error) {
|
|
42
|
+
verboseLog(error);
|
|
43
|
+
row.error = t('app.deploy.error.network', { url: APPS_API_URL, message: error.message });
|
|
44
|
+
return row;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
const body = response.data && typeof response.data === 'object' ? response.data : {};
|
|
48
|
+
const data = body.data ?? {};
|
|
49
|
+
|
|
50
|
+
row.status = data.status ?? null;
|
|
51
|
+
row.warnings = Array.isArray(data.warnings) ? data.warnings.map((warning) => warning.message) : [];
|
|
52
|
+
|
|
53
|
+
if (response.status >= 200 && response.status < 300) {
|
|
54
|
+
if (data.result === 'skipped') {
|
|
55
|
+
row.result = data.live ? 'already_live' : 'already_pending';
|
|
56
|
+
} else {
|
|
57
|
+
row.result = data.live ? 'published' : 'pending_review';
|
|
58
|
+
}
|
|
59
|
+
return row;
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
verboseLog(response.status, body);
|
|
63
|
+
|
|
64
|
+
const serverMessage = body.errors?.file?.[0] ?? Object.values(body.errors ?? {})[0]?.[0] ?? body.message;
|
|
65
|
+
|
|
66
|
+
if (response.status === 401) {
|
|
67
|
+
row.error = t('app.deploy.error.unauthorized');
|
|
68
|
+
} else if (response.status === 403) {
|
|
69
|
+
row.error = t('app.deploy.error.forbidden');
|
|
70
|
+
} else if (response.status === 404) {
|
|
71
|
+
row.error = t('app.deploy.error.app_not_found', { prefix: pkg.prefix });
|
|
72
|
+
} else {
|
|
73
|
+
row.error = serverMessage || t('app.deploy.error.http', { status: response.status });
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return row;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function printRow(row) {
|
|
80
|
+
const label = row.prefix ? `${row.prefix} ${row.version}` : row.dir;
|
|
81
|
+
|
|
82
|
+
switch (row.result) {
|
|
83
|
+
case 'published':
|
|
84
|
+
console.log(chalk.green(t('app.deploy.published', { label })));
|
|
85
|
+
break;
|
|
86
|
+
case 'pending_review':
|
|
87
|
+
console.log(chalk.yellow(t('app.deploy.pending_review', { label })));
|
|
88
|
+
break;
|
|
89
|
+
case 'already_live':
|
|
90
|
+
console.log(chalk.gray(t('app.deploy.already_live', { label })));
|
|
91
|
+
break;
|
|
92
|
+
case 'already_pending':
|
|
93
|
+
console.log(chalk.gray(t('app.deploy.already_pending', { label })));
|
|
94
|
+
break;
|
|
95
|
+
default:
|
|
96
|
+
console.log(chalk.red(t('app.deploy.failed', { label })));
|
|
97
|
+
console.log(chalk.red(` ${row.error}`));
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (row.result === 'pending_review' || row.result === 'published') {
|
|
101
|
+
for (const warning of row.warnings) {
|
|
102
|
+
console.log(chalk.gray(` • ${warning}`));
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/**
|
|
108
|
+
* tsoft app deploy [dirs...] — CI-friendly: no login, token from --token or TSOFT_DEPLOY_TOKEN.
|
|
109
|
+
* Exits non-zero when any package failed; a version left in review is not a failure.
|
|
110
|
+
*/
|
|
111
|
+
export async function appDeployCommand(dirs, options = {}, { post = axios.post, exit = process.exit } = {}) {
|
|
112
|
+
const token = (options.token ?? process.env[DEPLOY_TOKEN_ENV] ?? '').trim();
|
|
113
|
+
const targets = dirs?.length ? dirs : ['.'];
|
|
114
|
+
|
|
115
|
+
if (!token) {
|
|
116
|
+
if (isJsonMode()) {
|
|
117
|
+
jsonOut({ status: 'error', command: 'app deploy', error: { message: t('app.deploy.error.no_token'), hint: t('app.deploy.error.no_token_hint') } });
|
|
118
|
+
} else {
|
|
119
|
+
console.log(chalk.red(t('app.deploy.error.no_token')));
|
|
120
|
+
console.log(chalk.gray(' ' + t('app.deploy.error.no_token_hint')));
|
|
121
|
+
}
|
|
122
|
+
exit(1);
|
|
123
|
+
return [];
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
const results = [];
|
|
127
|
+
|
|
128
|
+
for (const dir of targets) {
|
|
129
|
+
const row = await deployOne(dir, token, post);
|
|
130
|
+
results.push(row);
|
|
131
|
+
|
|
132
|
+
if (!isJsonMode()) {
|
|
133
|
+
printRow(row);
|
|
134
|
+
}
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const failed = results.filter((row) => row.result === 'failed').length;
|
|
138
|
+
|
|
139
|
+
if (isJsonMode()) {
|
|
140
|
+
jsonOut({ status: failed ? 'error' : 'success', command: 'app deploy', data: { results } });
|
|
141
|
+
} else if (targets.length > 1) {
|
|
142
|
+
console.log();
|
|
143
|
+
console.log(t('app.deploy.summary', {
|
|
144
|
+
total: results.length,
|
|
145
|
+
published: results.filter((row) => row.result === 'published').length,
|
|
146
|
+
pending: results.filter((row) => row.result === 'pending_review').length,
|
|
147
|
+
skipped: results.filter((row) => row.result.startsWith('already_')).length,
|
|
148
|
+
failed,
|
|
149
|
+
}));
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
if (failed) {
|
|
153
|
+
exit(1);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
return results;
|
|
157
|
+
}
|
package/src/commands/login.js
CHANGED
|
@@ -2,6 +2,7 @@ import open from 'open';
|
|
|
2
2
|
import chalk from 'chalk';
|
|
3
3
|
import {generateCodeVerifier, generateCodeChallenge} from '../pkce.js';
|
|
4
4
|
import {startCallbackServer} from '../server.js';
|
|
5
|
+
import { findPortOwner } from '../port-owner.js';
|
|
5
6
|
import {exchangeToken, saveTokens, setActiveStore} from '../storage.js';
|
|
6
7
|
import * as config from '../config.js';
|
|
7
8
|
import { isJsonMode, jsonOut, verboseLog } from '../output-mode.js';
|
|
@@ -94,13 +95,21 @@ export async function loginCommand() {
|
|
|
94
95
|
}
|
|
95
96
|
}
|
|
96
97
|
|
|
97
|
-
function exitOnListenFailure(error, port) {
|
|
98
|
+
async function exitOnListenFailure(error, port) {
|
|
98
99
|
verboseLog(error.message);
|
|
99
100
|
|
|
100
101
|
if (error.code === 'EADDRINUSE') {
|
|
102
|
+
const owner = await findPortOwner(port);
|
|
101
103
|
console.error(chalk.red('\n❌ ' + t('login.port_in_use.message', { port })));
|
|
104
|
+
if (owner) {
|
|
105
|
+
console.error(chalk.gray(' ' + t('login.port_in_use.owner', { command: owner.command || '-', pid: owner.pid })));
|
|
106
|
+
}
|
|
102
107
|
console.error(chalk.gray(' ' + t('login.port_in_use.close_hint')));
|
|
103
|
-
|
|
108
|
+
if (owner) {
|
|
109
|
+
console.error(chalk.gray(' ' + t('login.port_in_use.stop_hint', { command: stopProcessCommand(owner.pid) })));
|
|
110
|
+
} else {
|
|
111
|
+
console.error(chalk.gray(' ' + t('login.port_in_use.find_hint', { command: findPortOwnerCommand(port) })));
|
|
112
|
+
}
|
|
104
113
|
} else {
|
|
105
114
|
console.error(chalk.red('\n❌ ' + t('login.server_start_failed.message')));
|
|
106
115
|
console.error(chalk.gray(' ' + t('login.server_start_failed.hint')));
|
|
@@ -109,6 +118,10 @@ function exitOnListenFailure(error, port) {
|
|
|
109
118
|
process.exit(1);
|
|
110
119
|
}
|
|
111
120
|
|
|
121
|
+
function stopProcessCommand(pid) {
|
|
122
|
+
return process.platform === 'win32' ? `taskkill /PID ${pid}` : `kill ${pid}`;
|
|
123
|
+
}
|
|
124
|
+
|
|
112
125
|
function findPortOwnerCommand(port) {
|
|
113
126
|
return process.platform === 'win32' ? `netstat -ano | findstr :${port}` : `lsof -i :${port}`;
|
|
114
127
|
}
|
|
@@ -2,7 +2,8 @@ import chalk from 'chalk';
|
|
|
2
2
|
import ora from 'ora';
|
|
3
3
|
import { createApiClient } from '../api-client.js';
|
|
4
4
|
import { describeActiveTheme, getActiveStore } from '../storage.js';
|
|
5
|
-
import { formatMegabytes, mapPushError } from '../errors/error-mapper.js';
|
|
5
|
+
import { formatMegabytes, mapPushError, themeNotFoundMessage, THEME_NOT_FOUND_CODE } from '../errors/error-mapper.js';
|
|
6
|
+
import { resolveThemeTarget } from '../theme-list.js';
|
|
6
7
|
import { isJsonMode, jsonOut } from '../output-mode.js';
|
|
7
8
|
import { t } from '../i18n.js';
|
|
8
9
|
|
|
@@ -17,6 +18,61 @@ const CHANGE_CATEGORIES = {
|
|
|
17
18
|
|
|
18
19
|
const LARGEST_FILES_SHOWN = 5;
|
|
19
20
|
|
|
21
|
+
class PushTargetError extends Error {
|
|
22
|
+
constructor({ message, hint, code, matches = [] }) {
|
|
23
|
+
super(message);
|
|
24
|
+
this.hint = hint;
|
|
25
|
+
this.code = code;
|
|
26
|
+
this.matches = matches;
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
async function resolvePushTarget(themeName, apiClient) {
|
|
31
|
+
const target = await resolveThemeTarget(themeName, apiClient);
|
|
32
|
+
|
|
33
|
+
if (target.status === 'ambiguous') {
|
|
34
|
+
throw new PushTargetError({
|
|
35
|
+
message: t('push.name_ambiguous.message', { name: themeName }),
|
|
36
|
+
hint: t('push.name_ambiguous.hint'),
|
|
37
|
+
code: 'THEME-NAME-AMBIGUOUS',
|
|
38
|
+
matches: target.matches,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (target.status === 'not_found') {
|
|
43
|
+
throw new PushTargetError({
|
|
44
|
+
message: themeNotFoundMessage(themeName),
|
|
45
|
+
hint: t('error.push.theme_not_found.hint'),
|
|
46
|
+
code: THEME_NOT_FOUND_CODE,
|
|
47
|
+
});
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return target.folder;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function exitOnTargetError(error) {
|
|
54
|
+
if (isJsonMode()) {
|
|
55
|
+
jsonOut({
|
|
56
|
+
status: 'error',
|
|
57
|
+
command: 'theme push',
|
|
58
|
+
error: {
|
|
59
|
+
message: error.message,
|
|
60
|
+
hint: error.hint,
|
|
61
|
+
code: error.code,
|
|
62
|
+
...(error.matches.length > 0 ? { details: { matches: error.matches.map(match => match.folder) } } : {}),
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
process.exit(1);
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
console.error(chalk.red(`X ${error.message}`));
|
|
69
|
+
for (const match of error.matches) {
|
|
70
|
+
console.log(chalk.gray(` - ${match.folder} ${match.name}`));
|
|
71
|
+
}
|
|
72
|
+
console.log(chalk.yellow(` ${error.hint}`));
|
|
73
|
+
process.exit(1);
|
|
74
|
+
}
|
|
75
|
+
|
|
20
76
|
const PARTNERS_BASE = 'https://partners.tsoft360.com';
|
|
21
77
|
const PARTNERS_THEMES_INDEX = `${PARTNERS_BASE}/themes`;
|
|
22
78
|
|
|
@@ -99,12 +155,12 @@ export async function themePushCommand(themeName, options = {}) {
|
|
|
99
155
|
const verbose = options.verbose || false;
|
|
100
156
|
|
|
101
157
|
// Tema belirleme
|
|
102
|
-
let themeUuid;
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
} else {
|
|
158
|
+
let themeUuid = null;
|
|
159
|
+
let themeLabel = themeName;
|
|
160
|
+
if (!themeName) {
|
|
106
161
|
const active = await describeActiveTheme();
|
|
107
162
|
themeUuid = active?.uuid ?? null;
|
|
163
|
+
themeLabel = active?.label ?? themeUuid;
|
|
108
164
|
if (active && !isJsonMode()) {
|
|
109
165
|
const store = await getActiveStore();
|
|
110
166
|
if (store) {
|
|
@@ -132,6 +188,10 @@ export async function themePushCommand(themeName, options = {}) {
|
|
|
132
188
|
spinner.text = t('push.checking_status');
|
|
133
189
|
const apiClient = await createApiClient();
|
|
134
190
|
|
|
191
|
+
if (themeName) {
|
|
192
|
+
themeUuid = await resolvePushTarget(themeName, apiClient);
|
|
193
|
+
}
|
|
194
|
+
|
|
135
195
|
try {
|
|
136
196
|
const statusResponse = await apiClient.getThemeDevStatus(themeUuid);
|
|
137
197
|
previousVersion = statusResponse.data?.current_version || null;
|
|
@@ -190,15 +250,20 @@ export async function themePushCommand(themeName, options = {}) {
|
|
|
190
250
|
} catch (error) {
|
|
191
251
|
spinner.stop();
|
|
192
252
|
|
|
253
|
+
if (error instanceof PushTargetError) {
|
|
254
|
+
exitOnTargetError(error);
|
|
255
|
+
}
|
|
256
|
+
|
|
193
257
|
// JSON mode error output
|
|
194
258
|
if (isJsonMode()) {
|
|
195
|
-
const mapped = mapPushError(error, false);
|
|
259
|
+
const mapped = mapPushError(error, false, { theme: themeLabel });
|
|
196
260
|
jsonOut({
|
|
197
261
|
status: 'error',
|
|
198
262
|
command: 'theme push',
|
|
199
263
|
error: {
|
|
200
264
|
message: mapped.message,
|
|
201
265
|
hint: mapped.hint || null,
|
|
266
|
+
...(mapped.code ? { code: mapped.code } : {}),
|
|
202
267
|
...(mapped.details ? { details: mapped.details } : {}),
|
|
203
268
|
}
|
|
204
269
|
});
|
|
@@ -207,7 +272,7 @@ export async function themePushCommand(themeName, options = {}) {
|
|
|
207
272
|
|
|
208
273
|
// Push hatasi (axios error, error.response mevcut)
|
|
209
274
|
if (error.response) {
|
|
210
|
-
const mapped = mapPushError(error, verbose);
|
|
275
|
+
const mapped = mapPushError(error, verbose, { theme: themeLabel });
|
|
211
276
|
console.error(chalk.red(`X ${mapped.message}`));
|
|
212
277
|
displayLargestFiles(mapped.details);
|
|
213
278
|
if (mapped.hint) {
|
package/src/config.js
CHANGED
|
@@ -17,3 +17,6 @@ export const getRefreshUrl = (store) =>
|
|
|
17
17
|
export const getRevokeUrl = (store) =>
|
|
18
18
|
`https://${store}/oauth/revoke`;
|
|
19
19
|
|
|
20
|
+
|
|
21
|
+
// T-Soft apps API (backend-core): app market, partner and deploy endpoints
|
|
22
|
+
export const APPS_API_URL = (process.env.TSOFT_APPS_API_URL ?? 'https://api.tsoftapps.com').replace(/\/+$/, '');
|
|
@@ -13,6 +13,10 @@ const ERROR_MAP = {
|
|
|
13
13
|
message: 'error.push.auth.message',
|
|
14
14
|
hint: 'error.push.auth.hint',
|
|
15
15
|
},
|
|
16
|
+
'PUSH-AUTH-REFRESH-FAILED': {
|
|
17
|
+
message: 'error.push.auth_refresh_failed.message',
|
|
18
|
+
hint: 'error.push.auth_refresh_failed.hint',
|
|
19
|
+
},
|
|
16
20
|
'PUSH-BACKEND-ERROR': {
|
|
17
21
|
message: 'error.push.backend_error.message',
|
|
18
22
|
hint: 'error.push.backend_error.hint',
|
|
@@ -33,6 +37,8 @@ const ERROR_MAP = {
|
|
|
33
37
|
};
|
|
34
38
|
|
|
35
39
|
const PAYLOAD_TOO_LARGE_STATUS = 413;
|
|
40
|
+
const THEME_MISSING_STATUSES = new Set([404, 410]);
|
|
41
|
+
export const THEME_NOT_FOUND_CODE = 'THEME-NOT-FOUND';
|
|
36
42
|
const BYTES_PER_MEGABYTE = 1048576;
|
|
37
43
|
|
|
38
44
|
const SMALLEST_ROUNDED_MEGABYTES = 0.1;
|
|
@@ -92,6 +98,23 @@ function mapPayloadTooLarge(responseData, verbose) {
|
|
|
92
98
|
};
|
|
93
99
|
}
|
|
94
100
|
|
|
101
|
+
export function themeNotFoundMessage(theme, serverMessage = null) {
|
|
102
|
+
const detail = typeof serverMessage === 'string' ? serverMessage.trim() : '';
|
|
103
|
+
|
|
104
|
+
return detail
|
|
105
|
+
? t('error.push.theme_not_found.message_with_detail', { theme, detail })
|
|
106
|
+
: t('error.push.theme_not_found.message', { theme });
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function mapThemeNotFound(responseData, theme, verbose) {
|
|
110
|
+
return {
|
|
111
|
+
message: themeNotFoundMessage(theme, responseData?.message),
|
|
112
|
+
hint: t('error.push.theme_not_found.hint'),
|
|
113
|
+
raw: verbose ? responseData : null,
|
|
114
|
+
code: THEME_NOT_FOUND_CODE,
|
|
115
|
+
};
|
|
116
|
+
}
|
|
117
|
+
|
|
95
118
|
const HTTP_STATUS_MAP = {
|
|
96
119
|
401: {
|
|
97
120
|
message: 'error.http.401.message',
|
|
@@ -121,7 +144,7 @@ const HTTP_STATUS_MAP = {
|
|
|
121
144
|
* @param {boolean} verbose - Detayli cikti
|
|
122
145
|
* @returns {{ message: string, hint: string|null, raw: object|null }}
|
|
123
146
|
*/
|
|
124
|
-
export function mapPushError(error, verbose = false) {
|
|
147
|
+
export function mapPushError(error, verbose = false, { theme = null } = {}) {
|
|
125
148
|
const responseData = error.response?.data;
|
|
126
149
|
const errorCode = responseData?.error_code;
|
|
127
150
|
const statusCode = error.response?.status;
|
|
@@ -137,9 +160,14 @@ export function mapPushError(error, verbose = false) {
|
|
|
137
160
|
message: t(mapped.message),
|
|
138
161
|
hint: t(mapped.hint),
|
|
139
162
|
raw: verbose ? responseData : null,
|
|
163
|
+
code: errorCode,
|
|
140
164
|
};
|
|
141
165
|
}
|
|
142
166
|
|
|
167
|
+
if (theme && THEME_MISSING_STATUSES.has(statusCode)) {
|
|
168
|
+
return mapThemeNotFound(responseData, theme, verbose);
|
|
169
|
+
}
|
|
170
|
+
|
|
143
171
|
// 2. Sunucu mesaji varsa (error_code bilinmiyor ama mesaj var)
|
|
144
172
|
if (responseData?.message && errorCode) {
|
|
145
173
|
return {
|
package/src/index.js
CHANGED
|
@@ -19,6 +19,7 @@ import { themeInitCommand } from './commands/theme-init.js';
|
|
|
19
19
|
import { orgSwitchCommand } from './commands/org-switch.js';
|
|
20
20
|
import { storeListCommand, storeUseCommand } from './commands/store.js';
|
|
21
21
|
import { docsCommand } from './commands/docs.js';
|
|
22
|
+
import { appDeployCommand } from './commands/app-deploy.js';
|
|
22
23
|
import { upgradeCommand, buildUpgradeNotice } from './commands/upgrade.js';
|
|
23
24
|
import { configAutoupgradeCommand } from './commands/config.js';
|
|
24
25
|
import { getAutoupgrade, getLastCheck, setLastCheck } from './cli-settings.js';
|
|
@@ -329,6 +330,19 @@ theme
|
|
|
329
330
|
await themeInitCommand(options.from);
|
|
330
331
|
});
|
|
331
332
|
|
|
333
|
+
// app commands
|
|
334
|
+
const app = program
|
|
335
|
+
.command('app')
|
|
336
|
+
.description('App commands');
|
|
337
|
+
|
|
338
|
+
app
|
|
339
|
+
.command('deploy [dirs...]')
|
|
340
|
+
.description('Upload storefront embed packages; live at once for first-party apps, otherwise sent to review')
|
|
341
|
+
.option('--token <token>', 'Organization deploy token (default: TSOFT_DEPLOY_TOKEN)')
|
|
342
|
+
.action(async (dirs, options) => {
|
|
343
|
+
await appDeployCommand(dirs, options);
|
|
344
|
+
});
|
|
345
|
+
|
|
332
346
|
// store commands
|
|
333
347
|
const store = program
|
|
334
348
|
.command('store')
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
import { execFile } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
|
|
4
|
+
const run = promisify(execFile);
|
|
5
|
+
const PROBE_TIMEOUT_MS = 3000;
|
|
6
|
+
|
|
7
|
+
export function parseLsofOutput(output) {
|
|
8
|
+
let pid = null;
|
|
9
|
+
let command = null;
|
|
10
|
+
|
|
11
|
+
for (const line of output.split('\n')) {
|
|
12
|
+
if (line.startsWith('p')) {
|
|
13
|
+
if (pid !== null) break;
|
|
14
|
+
pid = Number(line.slice(1));
|
|
15
|
+
} else if (line.startsWith('c') && pid !== null && command === null) {
|
|
16
|
+
command = line.slice(1).trim();
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
return Number.isInteger(pid) && pid > 0 ? { pid, command: command || null } : null;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function parseNetstatOutput(output, port) {
|
|
24
|
+
for (const line of output.split(/\r?\n/)) {
|
|
25
|
+
const columns = line.trim().split(/\s+/);
|
|
26
|
+
if (columns.length < 5 || columns[0].toUpperCase() !== 'TCP') continue;
|
|
27
|
+
if (columns[3].toUpperCase() !== 'LISTENING') continue;
|
|
28
|
+
if (!columns[1].endsWith(`:${port}`)) continue;
|
|
29
|
+
|
|
30
|
+
const pid = Number(columns[4]);
|
|
31
|
+
if (Number.isInteger(pid) && pid > 0) return pid;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
return null;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function parseTasklistOutput(output) {
|
|
38
|
+
const match = output.match(/^"([^"]+)","\d+"/m);
|
|
39
|
+
return match ? match[1] : null;
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
async function findWindowsPortOwner(port) {
|
|
43
|
+
const { stdout } = await run('netstat', ['-ano', '-p', 'TCP'], { timeout: PROBE_TIMEOUT_MS, windowsHide: true });
|
|
44
|
+
const pid = parseNetstatOutput(stdout, port);
|
|
45
|
+
if (pid === null) return null;
|
|
46
|
+
|
|
47
|
+
try {
|
|
48
|
+
const { stdout: tasks } = await run('tasklist', ['/FI', `PID eq ${pid}`, '/FO', 'CSV', '/NH'], {
|
|
49
|
+
timeout: PROBE_TIMEOUT_MS,
|
|
50
|
+
windowsHide: true,
|
|
51
|
+
});
|
|
52
|
+
return { pid, command: parseTasklistOutput(tasks) };
|
|
53
|
+
} catch {
|
|
54
|
+
return { pid, command: null };
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function findUnixPortOwner(port) {
|
|
59
|
+
const { stdout } = await run('lsof', ['-nP', `-iTCP:${port}`, '-sTCP:LISTEN', '-Fpc'], { timeout: PROBE_TIMEOUT_MS });
|
|
60
|
+
return parseLsofOutput(stdout);
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
export async function findPortOwner(port) {
|
|
64
|
+
try {
|
|
65
|
+
return process.platform === 'win32'
|
|
66
|
+
? await findWindowsPortOwner(port)
|
|
67
|
+
: await findUnixPortOwner(port);
|
|
68
|
+
} catch {
|
|
69
|
+
return null;
|
|
70
|
+
}
|
|
71
|
+
}
|
package/src/theme-list.js
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { createApiClient } from './api-client.js';
|
|
2
|
+
import { isThemeUuid, slugify } from './storage.js';
|
|
2
3
|
|
|
3
4
|
export async function fetchAllThemes(client = null) {
|
|
4
5
|
const apiClient = client ?? await createApiClient();
|
|
@@ -23,3 +24,46 @@ export async function fetchAllThemes(client = null) {
|
|
|
23
24
|
|
|
24
25
|
return { themes: all, envelope };
|
|
25
26
|
}
|
|
27
|
+
|
|
28
|
+
function folderOf(theme) {
|
|
29
|
+
return theme.theme_folder || theme.alias || null;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
function sameName(name, identifier) {
|
|
33
|
+
if (typeof name !== 'string') return false;
|
|
34
|
+
return name.trim().toLowerCase() === identifier.trim().toLowerCase() || slugify(name) === identifier;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export async function resolveThemeTarget(identifier, client) {
|
|
38
|
+
if (isThemeUuid(identifier)) {
|
|
39
|
+
return { status: 'direct', folder: identifier };
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
let themes;
|
|
43
|
+
try {
|
|
44
|
+
({ themes } = await fetchAllThemes(client));
|
|
45
|
+
} catch {
|
|
46
|
+
return { status: 'direct', folder: identifier };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (themes.some(theme => theme.theme_folder === identifier || theme.alias === identifier)) {
|
|
50
|
+
return { status: 'direct', folder: identifier };
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const matches = themes
|
|
54
|
+
.filter(theme => sameName(theme.name, identifier) && folderOf(theme))
|
|
55
|
+
.filter((theme, index, all) => all.findIndex(other => folderOf(other) === folderOf(theme)) === index);
|
|
56
|
+
|
|
57
|
+
if (matches.length === 1) {
|
|
58
|
+
return { status: 'resolved', folder: folderOf(matches[0]), name: matches[0].name };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
if (matches.length > 1) {
|
|
62
|
+
return {
|
|
63
|
+
status: 'ambiguous',
|
|
64
|
+
matches: matches.map(theme => ({ name: theme.name, folder: folderOf(theme) })),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
return { status: 'not_found' };
|
|
69
|
+
}
|
package/src/ui/help-renderer.js
CHANGED
|
@@ -56,6 +56,12 @@ const COMMAND_GROUPS = [
|
|
|
56
56
|
{ display: 'theme fork', descKey: 'help.cmd.theme_init' },
|
|
57
57
|
]
|
|
58
58
|
},
|
|
59
|
+
{
|
|
60
|
+
groupKey: 'help.group.apps',
|
|
61
|
+
commands: [
|
|
62
|
+
{ display: 'app deploy', descKey: 'help.cmd.app_deploy' },
|
|
63
|
+
]
|
|
64
|
+
},
|
|
59
65
|
{
|
|
60
66
|
groupKey: 'help.group.account',
|
|
61
67
|
commands: [
|