tabby-profile-share 0.0.0-stage → 0.1.2
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 +25 -0
- package/LICENSE +21 -0
- package/README.md +98 -2
- package/README.pt-BR.md +81 -0
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/settingsProvider.d.ts +8 -0
- package/dist/share.component.d.ts +50 -0
- package/package.json +62 -4
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## Unreleased
|
|
4
|
+
|
|
5
|
+
- Open-source release preparation: MIT license, public documentation, security policy and contribution guidelines.
|
|
6
|
+
- npm package metadata and release checklists.
|
|
7
|
+
|
|
8
|
+
## 0.1.2 — Prepared, not yet published to npm
|
|
9
|
+
|
|
10
|
+
- Bump version for first public preview.
|
|
11
|
+
- Review Git history and document Angular 15 compatibility/security advisory boundaries.
|
|
12
|
+
- Add complete development audit visibility and npm name lookup to CI.
|
|
13
|
+
|
|
14
|
+
## 0.1.1 — Preview (2026-10-24)
|
|
15
|
+
|
|
16
|
+
- Fixed missing author metadata for Tabby plugin discovery.
|
|
17
|
+
- Added packaging, GitHub Actions and automated unit testing.
|
|
18
|
+
- First manual tests confirmed plugin load and SSH export.
|
|
19
|
+
|
|
20
|
+
## 0.1.0 — Development preview (2026-10-24)
|
|
21
|
+
|
|
22
|
+
- Standalone settings plugin for Tabby.
|
|
23
|
+
- Selective SSH export with folder hierarchy and explicit username opt-in.
|
|
24
|
+
- JSON sanitizer, append-only import and username prompt.
|
|
25
|
+
- Unit tests for secrets, format validation and ID remapping.
|
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 gugahartmann and Tabby Profile Share contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
CHANGED
|
@@ -1,3 +1,99 @@
|
|
|
1
|
-
#
|
|
1
|
+
# Tabby Profile Share
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
**Share SSH profiles and their folder hierarchy safely with your team.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/gugahartmann/Tabby_Profile_Share/actions/workflows/test.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
[Português (Brasil)](README.pt-BR.md)
|
|
9
|
+
|
|
10
|
+
Tabby Profile Share is an **independent, community-maintained plugin** for [Tabby by Eugeny](https://github.com/Eugeny/tabby). It does **not** modify Tabby, require the Tabby Performance fork, or have any affiliation with or endorsement from the Tabby maintainers.
|
|
11
|
+
|
|
12
|
+
> **Status:** Preview 0.1.2 (unpublished) — manual installation and SSH export confirmed by an early tester. Wider compatibility, import scenarios and public npm publication remain separate validation steps. Do not assume all Tabby versions have been tested.
|
|
13
|
+
|
|
14
|
+
## Features
|
|
15
|
+
|
|
16
|
+
- Choose **folders, subfolders or individual SSH profiles** using a tree with checkboxes.
|
|
17
|
+
- Review distinct SSH usernames from the selected profiles; **all usernames are unchecked by default**. You explicitly choose which shared usernames to include.
|
|
18
|
+
- Export a portable, versioned `.tabby-profiles.json` file, preserving group hierarchy, host, SSH port and allowlisted connection settings.
|
|
19
|
+
- Exclude **passwords, login scripts, key material and key paths, and proxy commands**. No Vault access or remote sync.
|
|
20
|
+
- Import into **new groups and new profiles**, generating new IDs and preserving pre-existing profiles.
|
|
21
|
+
- Prompt for an SSH username when imported profiles have no username; display up to four example devices; retain explicitly shared usernames.
|
|
22
|
+
- Handle bundled jump-host relationships when both referenced profiles are in the export.
|
|
23
|
+
|
|
24
|
+
### What is intentionally not shared?
|
|
25
|
+
|
|
26
|
+
The file contains **no stored credentials**, private key paths or login scripts. Some advanced SSH options are deliberately not portable; these must be configured on the destination machine.
|
|
27
|
+
|
|
28
|
+
**Important:** Exported profiles still include hostnames, device labels, private IP addresses, and any usernames you deliberately selected. These are potentially sensitive network inventory. Treat the exported file as confidential, and inspect it before sharing it outside your organization.
|
|
29
|
+
|
|
30
|
+
## Install
|
|
31
|
+
|
|
32
|
+
### Once the package is available on npm
|
|
33
|
+
|
|
34
|
+
Open **Tabby → Settings → Plugins**, search for **profile-share**, install `tabby-profile-share`, and restart Tabby if requested. Tabby discovers community plugins distributed with the `tabby-plugin` npm keyword.
|
|
35
|
+
|
|
36
|
+
**Not yet published to npm:** Until the package appears in npm search, use the preview instructions below. Publishing status should not be inferred from this README.
|
|
37
|
+
|
|
38
|
+
### Preview: manual install on Windows
|
|
39
|
+
|
|
40
|
+
1. Download the latest compiled ZIP from [Actions](https://github.com/gugahartmann/Tabby_Profile_Share/actions/workflows/test.yml) (or a future GitHub release).
|
|
41
|
+
2. The Actions artifact contains another ZIP with a top-level directory called `tabby-profile-share`. Extract that directory.
|
|
42
|
+
3. Close Tabby fully, including the tray icon, and copy the folder to:
|
|
43
|
+
`%APPDATA%\tabby\plugins\node_modules\tabby-profile-share\`
|
|
44
|
+
4. Verify that both files exist inside that directory: `package.json` and `dist/index.js`.
|
|
45
|
+
5. Restart Tabby and open **Settings → Profile Sharing**.
|
|
46
|
+
|
|
47
|
+
Do not install the directory one level too high, or nest `tabby-profile-share` twice.
|
|
48
|
+
|
|
49
|
+
## Use
|
|
50
|
+
|
|
51
|
+
**Export:** Select the folder tree, review the list of usernames (unchecked by default), explicitly enable only shared/team usernames, then click **Export**. Inspect the resulting JSON before distributing it.
|
|
52
|
+
|
|
53
|
+
**Import:** Make a backup of your Tabby configuration, open the **Import** tab, select the JSON, review its counts and confirm. The plugin adds new items; it does not replace existing entries. If any imported profiles lack usernames, it asks for one username to apply to those new profiles, or allows you to leave them blank.
|
|
54
|
+
|
|
55
|
+
See the [functional specification](docs/SPEC.md) for detailed behavior. Import has automated unit coverage, but full UI regression across Tabby releases remains on the roadmap.
|
|
56
|
+
|
|
57
|
+
## Publish via GitHub Actions (no local Git or Node.js required)
|
|
58
|
+
|
|
59
|
+
Maintainers can prepare npm releases entirely in the browser with the **Stage npm package** workflow. The first release uses a temporary npm stage-only token in the repository secret `NPM_STAGE_TOKEN`; subsequent releases use npm Trusted Publishing (OIDC) with no permanent token. Every release is reviewed and approved on npmjs.com with 2FA before it goes live.
|
|
60
|
+
|
|
61
|
+
See the [complete step-by-step npm GitHub Actions guide](docs/PUBLISHING.md). Publishing is manual and never triggered by pushes or pull requests.
|
|
62
|
+
|
|
63
|
+
## Development
|
|
64
|
+
|
|
65
|
+
Requirements: Node.js 22 and npm.
|
|
66
|
+
|
|
67
|
+
```bash
|
|
68
|
+
git clone https://github.com/gugahartmann/Tabby_Profile_Share.git
|
|
69
|
+
cd Tabby_Profile_Share
|
|
70
|
+
npm install --legacy-peer-deps
|
|
71
|
+
npm test
|
|
72
|
+
npm run build
|
|
73
|
+
npm pack --dry-run
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
The production entry point is `dist/index.js`. Angular and Tabby dependencies are external to the generated bundle, loaded by the host Tabby application.
|
|
77
|
+
|
|
78
|
+
The repository includes CI for unit tests, compilation and packaging. Before a public release, verify the packaged files, audit dependencies, and test import twice on a disposable Tabby configuration.
|
|
79
|
+
|
|
80
|
+
## Feedback and issue reports
|
|
81
|
+
|
|
82
|
+
Open an issue using one of the [three guided templates](https://github.com/gugahartmann/Tabby_Profile_Share/issues/new/choose): **Bug Report**, **Feature Request**, or **Custom / Question**. The forms follow the concise reporting style seen in the [Tabby Better Sidebar community](https://github.com/TooMuhtsh/tabby-better-sidebar/issues), with extra checks to protect SSH infrastructure data.
|
|
83
|
+
|
|
84
|
+
Please **never attach real SSH exports, internal hosts/IPs, login scripts, keys, passwords or unredacted logs**. Use synthetic samples. For security vulnerabilities, follow [SECURITY.md](SECURITY.md) rather than opening a public issue.
|
|
85
|
+
|
|
86
|
+
## Contributing and security
|
|
87
|
+
|
|
88
|
+
- [Contributing guide](CONTRIBUTING.md)
|
|
89
|
+
- [Security policy](SECURITY.md)
|
|
90
|
+
- [Changelog](CHANGELOG.md)
|
|
91
|
+
- [Release/publishing guide](docs/PUBLISHING.md)
|
|
92
|
+
|
|
93
|
+
Avoid committing real SSH exports, company network inventories or credentials in issues, pull requests, tests or screenshots. Report potential vulnerabilities privately using the instructions in `SECURITY.md`.
|
|
94
|
+
|
|
95
|
+
## License
|
|
96
|
+
|
|
97
|
+
[MIT](LICENSE) © 2026 gugahartmann and contributors.
|
|
98
|
+
|
|
99
|
+
Tabby is a separate project, developed and licensed independently by its authors. Tabby Profile Share is a community plugin, **not an official Tabby product**.
|
package/README.pt-BR.md
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
# Tabby Profile Share
|
|
2
|
+
|
|
3
|
+
**Compartilhe perfis SSH e a hierarquia de pastas do Tabby com segurança.**
|
|
4
|
+
|
|
5
|
+
[](https://github.com/gugahartmann/Tabby_Profile_Share/actions/workflows/test.yml)
|
|
6
|
+
[](LICENSE)
|
|
7
|
+
|
|
8
|
+
[English](README.md)
|
|
9
|
+
|
|
10
|
+
O **Tabby Profile Share** é um plugin **independente, mantido pela comunidade**, compatível com o [Tabby do Eugeny](https://github.com/Eugeny/tabby). Não exige o fork Tabby Performance nem modifica o código original do Tabby.
|
|
11
|
+
|
|
12
|
+
> **Estado:** Preview 0.1.2 (ainda não publicada). A instalação manual e a exportação foram confirmadas em teste inicial. A compatibilidade entre versões e todos os fluxos de importação ainda precisam de testes adicionais. O pacote ainda não foi publicado no npm.
|
|
13
|
+
|
|
14
|
+
## Funcionalidades
|
|
15
|
+
|
|
16
|
+
- Exportar pastas, subpastas e perfis SSH individuais pela árvore com caixas de seleção.
|
|
17
|
+
- Listar usuários SSH distintos dos perfis selecionados. **Todos desmarcados por padrão**; você escolhe quais logins coletivos compartilhar.
|
|
18
|
+
- Preservar hierarquia, hostname/IP, porta SSH, algoritmos aceitos e outras opções expressamente permitidas.
|
|
19
|
+
- **Nunca exportar senhas, scripts de login, chaves privadas, caminhos de chaves e comandos proxy.**
|
|
20
|
+
- Importar como **novos grupos e novos perfis**, sem sobrescrever os dados existentes.
|
|
21
|
+
- Solicitar usuário SSH para os novos equipamentos sem login, mostrando até quatro exemplos e a quantidade restante.
|
|
22
|
+
- Preservar relações de jump host quando os dois perfis estiverem incluídos no mesmo pacote.
|
|
23
|
+
|
|
24
|
+
**Atenção à privacidade:** O JSON não contém credenciais armazenadas, mas **contém inventário de rede** (nomes, hosts, IPs internos e usuários expressamente selecionados). Compartilhe somente com destinatários autorizados e revise o arquivo antes de enviá-lo.
|
|
25
|
+
|
|
26
|
+
## Instalação
|
|
27
|
+
|
|
28
|
+
### Pelo próprio Tabby (após publicação no npm)
|
|
29
|
+
|
|
30
|
+
Abra **Settings → Plugins**, procure **profile-share** / `tabby-profile-share`, instale o pacote e reinicie, se solicitado.
|
|
31
|
+
|
|
32
|
+
O plugin ainda **não está publicado no npm**. Até lá, use o pacote compilado:
|
|
33
|
+
|
|
34
|
+
### Instalação manual — Windows
|
|
35
|
+
|
|
36
|
+
1. Acesse [GitHub Actions](https://github.com/gugahartmann/Tabby_Profile_Share/actions/workflows/test.yml), escolha uma execução aprovada e baixe o ZIP do plugin.
|
|
37
|
+
2. Extraia a pasta `tabby-profile-share` que está dentro do ZIP do pacote.
|
|
38
|
+
3. Feche completamente o Tabby, inclusive a bandeja.
|
|
39
|
+
4. Copie para:
|
|
40
|
+
`%APPDATA%\tabby\plugins\node_modules\tabby-profile-share\`
|
|
41
|
+
5. Verifique `package.json` e `dist/index.js` nessa pasta e inicie o Tabby.
|
|
42
|
+
6. Abra a seção **Profile Sharing** em configurações.
|
|
43
|
+
|
|
44
|
+
Evite colocar a pasta diretamente em `plugins` ou aninhar duas pastas `tabby-profile-share`.
|
|
45
|
+
|
|
46
|
+
## Como utilizar
|
|
47
|
+
|
|
48
|
+
**Exportação:** marque as pastas e equipamentos que deseja compartilhar, selecione apenas os usuários SSH de equipe que devem acompanhar o arquivo e clique em **Export**.
|
|
49
|
+
|
|
50
|
+
**Importação:** faça backup das configurações, carregue o JSON, confira as quantidades e confirme. Novos grupos e perfis serão criados. Para perfis sem username, informe o usuário desejado ou prossiga sem preencher.
|
|
51
|
+
|
|
52
|
+
## Reportar bugs e sugerir melhorias
|
|
53
|
+
|
|
54
|
+
Acesse [Nova issue](https://github.com/gugahartmann/Tabby_Profile_Share/issues/new/choose) e escolha **Bug Report**, **Feature Request** ou **Custom / Question**. Os formulários seguem a estrutura objetiva das sugestões da comunidade [Tabby Better Sidebar](https://github.com/TooMuhtsh/tabby-better-sidebar/issues), acrescentando verificações de privacidade específicas para perfis SSH.
|
|
55
|
+
|
|
56
|
+
**Nunca anexe exports reais, endereços IP internos, nomes de equipamentos de produção, chaves, senhas ou logs sem anonimização.** Para falhas de segurança, siga [SECURITY.md](SECURITY.md).
|
|
57
|
+
|
|
58
|
+
## Publicação pelo GitHub Actions (sem Git ou Node.js local)
|
|
59
|
+
|
|
60
|
+
O mantenedor pode publicar usando somente o navegador: **GitHub → Actions → Stage npm package**. A primeira publicação exige um token npm temporário de **stage only** armazenado no Secret `NPM_STAGE_TOKEN`. As próximas versões usam Trusted Publishing (OIDC) sem token permanente. Cada versão passa por aprovação na interface do npm com 2FA.
|
|
61
|
+
|
|
62
|
+
Confira o [guia completo de publicação via GitHub Actions](docs/PUBLISHING.md). Nenhuma publicação é disparada automaticamente por commits ou PRs.
|
|
63
|
+
|
|
64
|
+
## Desenvolvimento
|
|
65
|
+
|
|
66
|
+
```bash
|
|
67
|
+
git clone https://github.com/gugahartmann/Tabby_Profile_Share.git
|
|
68
|
+
cd Tabby_Profile_Share
|
|
69
|
+
npm install --legacy-peer-deps
|
|
70
|
+
npm test
|
|
71
|
+
npm run build
|
|
72
|
+
npm pack --dry-run
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
O código-fonte é aberto. Veja [como contribuir](CONTRIBUTING.md), [política de segurança](SECURITY.md), [histórico](CHANGELOG.md) e [publicação no npm](docs/PUBLISHING.md).
|
|
76
|
+
|
|
77
|
+
**Nunca envie exports reais de rede, endereços de produção, credenciais, capturas sensíveis ou arquivos de configuração empresarial em issues ou pull requests.**
|
|
78
|
+
|
|
79
|
+
## Licença
|
|
80
|
+
|
|
81
|
+
[MIT](LICENSE) © 2026 gugahartmann e contribuidores. Projeto comunitário sem endosso ou vínculo oficial com a equipe de desenvolvimento do Tabby.
|
package/dist/index.d.ts
ADDED
package/dist/index.js
ADDED
|
@@ -0,0 +1 @@
|
|
|
1
|
+
(()=>{var e={247(e,t,r){var o=r(978);e.exports=(o.default||o).apply(o,[])},978(e,t,r){r(766),e.exports=function(e){return""+'<div class="p-3"><h2>Profile Sharing</h2><p class="text-muted">Export or import SSH profiles safely, with no passwords, login scripts or private keys.</p><div class="btn-group mb-3"><button class="btn" [class.btn-primary]="tab === "export"" [class.btn-outline-secondary]="tab !== "export"" (click)="tab = "export"">Export</button><button class="btn" [class.btn-primary]="tab === "import"" [class.btn-outline-secondary]="tab !== "import"" (click)="tab = "import"">Import</button></div><div class="alert alert-danger" *ngIf="error">{{error}}</div><div class="alert alert-success" *ngIf="info">{{info}}</div><div *ngIf="tab === "export""><div class="d-flex align-items-center justify-content-between mb-2"><h4>Folders and devices</h4><div><button class="btn btn-sm btn-outline-secondary me-2" (click)="selectAll(true)">Select all</button><button class="btn btn-sm btn-outline-secondary" (click)="selectAll(false)">Clear</button></div></div><p class="text-muted">Select the folders and SSH devices to export. Only selected devices are included.</p><div class="border rounded p-2 mb-3" style="max-height: 320px; overflow-y: auto"><ng-container *ngFor="let node of visible()"><div class="d-flex align-items-center mb-1" [style.paddingLeft.px]="node.level * 20"><button class="btn btn-sm btn-link p-0 me-1" *ngIf="node.children.length" (click)="node.expanded = !node.expanded">{{node.expanded ? \'▾\' : \'▸\'}}</button><span class="me-2" *ngIf="!node.children.length">📁</span><input class="form-check-input me-2" type="checkbox" [checked]="node.selected" [indeterminate]="node.partial" (change)="setNode(node, $any($event.target).checked)"><strong>{{node.name}}</strong><small class="text-muted ms-2">({{node.count}})</small></div><ng-container *ngIf="node.expanded"><div class="d-flex align-items-center mb-1" *ngFor="let device of node.profiles" [style.paddingLeft.px]="(node.level + 1) * 20 + 13"><input class="form-check-input me-2" type="checkbox" [checked]="selectedIds.has(device.id)" (change)="setProfile(device, $any($event.target).checked)"><span class="me-2">🖥</span><span>{{device.name}}</span><small class="text-muted ms-2">{{device.options?.host}}</small></div></ng-container></ng-container></div><h4>SSH usernames</h4><p class="text-muted">Usernames below are grouped and <strong>unchecked by default</strong>. Select only the shared team accounts. Unchecked users are removed from the export.</p><div class="border rounded p-2 mb-3" style="max-height: 240px; overflow-y: auto"><div class="text-muted" *ngIf="!users.length">Select devices to see their SSH usernames.</div><div class="form-check" *ngFor="let item of users"><input class="form-check-input" type="checkbox" [checked]="sharedUsers.has(item.user)" (change)="toggleUser(item.user, $any($event.target).checked)"><label class="form-check-label">{{item.user}} — {{item.count}} devices</label></div></div><p class="small text-muted">Login scripts, stored passwords, private keys, private key paths and proxy commands are never exported.</p><button class="btn btn-primary" [disabled]="!chosen.length" (click)="export()">Export {{chosen.length}} SSH profiles</button></div><div *ngIf="tab === "import""><h4>Import package</h4><p class="text-muted">Imported entries are always new. Existing folders, profiles and usernames are never overwritten.</p><input class="form-control mb-3" type="file" accept=".json,.tabby-profiles.json,application/json" (change)="onFile($event)"><div class="card mb-3" *ngIf="imported"><div class="card-body"><h5>{{importFileName}}</h5><p>{{importSummary}}</p><p class="text-muted small">Profiles without usernames will ask for a single SSH username; you can leave it blank.</p><button class="btn btn-primary" [disabled]="busy" (click)="import()">{{busy ? \'Importing...\' : \'Import as new profiles\'}}</button></div></div></div></div>'}},766(e,t,r){"use strict";var o=Object.prototype.hasOwnProperty;function s(e,t){return Array.isArray(e)?function(e,t){for(var r,o="",n="",i=Array.isArray(t),a=0;a<e.length;a++)(r=s(e[a]))&&(i&&t[a]&&(r=l(r)),o=o+n+r,n=" ");return o}(e,t):e&&"object"==typeof e?function(e){var t="",r="";for(var s in e)s&&e[s]&&o.call(e,s)&&(t=t+r+s,r=" ");return t}(e):e||""}function n(e){if(!e)return"";if("object"==typeof e){var t="";for(var r in e)o.call(e,r)&&(t=t+r+":"+e[r]+";");return t}return e+""}function i(e,t,r,o){if(!1===t||null==t||!t&&("class"===e||"style"===e))return"";if(!0===t)return" "+(o?e:e+'="'+e+'"');var s=typeof t;return"object"!==s&&"function"!==s||"function"!=typeof t.toJSON||(t=t.toJSON()),"string"==typeof t||(t=JSON.stringify(t),r||-1===t.indexOf('"'))?(r&&(t=l(t))," "+e+'="'+t+'"'):" "+e+"='"+t.replace(/'/g,"'")+"'"}t.merge=function e(t,r){if(1===arguments.length){for(var o=t[0],s=1;s<t.length;s++)o=e(o,t[s]);return o}for(var i in r)if("class"===i){var a=t[i]||[];t[i]=(Array.isArray(a)?a:[a]).concat(r[i]||[])}else if("style"===i){a=(a=n(t[i]))&&";"!==a[a.length-1]?a+";":a;var l=n(r[i]);l=l&&";"!==l[l.length-1]?l+";":l,t[i]=a+l}else t[i]=r[i];return t},t.classes=s,t.style=n,t.attr=i,t.attrs=function(e,t){var r="";for(var a in e)if(o.call(e,a)){var l=e[a];if("class"===a){r=i(a,l=s(l),!1,t)+r;continue}"style"===a&&(l=n(l)),r+=i(a,l,!1,t)}return r};var a=/["&<>]/;function l(e){var t=""+e,r=a.exec(t);if(!r)return e;var o,s,n,i="";for(o=r.index,s=0;o<t.length;o++){switch(t.charCodeAt(o)){case 34:n=""";break;case 38:n="&";break;case 60:n="<";break;case 62:n=">";break;default:continue}s!==o&&(i+=t.substring(s,o)),s=o+1,i+=n}return s!==o?i+t.substring(s,o):i}t.escape=l,t.rethrow=function e(t,o,s,n){if(!(t instanceof Error))throw t;if(!("undefined"==typeof window&&o||n))throw t.message+=" on line "+s,t;var i,a,l,c;try{n=n||r(21).readFileSync(o,{encoding:"utf8"}),i=3,a=n.split("\n"),l=Math.max(s-i,0),c=Math.min(a.length,s+i)}catch(r){return t.message+=" - could not read from "+o+" ("+r.message+")",void e(t,null,s)}i=a.slice(l,c).map(function(e,t){var r=t+l+1;return(r==s?" > ":" ")+r+"| "+e}).join("\n"),t.path=o;try{t.message=(o||"Pug")+":"+s+"\n"+i+"\n\n"+t.message}catch(e){}throw t}},729(e,t,r){"use strict";var o=this&&this.__decorate||function(e,t,r,o){var s,n=arguments.length,i=n<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)i=Reflect.decorate(e,t,r,o);else for(var a=e.length-1;a>=0;a--)(s=e[a])&&(i=(n<3?s(i):n>3?s(t,r,i):s(t,r))||i);return n>3&&i&&Object.defineProperty(t,r,i),i};Object.defineProperty(t,"__esModule",{value:!0});const s=r(430),n=r(804),i=r(440),a=r(643),l=r(262),c=r(977),p=r(207);let d=class{};d=o([(0,s.NgModule)({imports:[n.CommonModule,i.FormsModule,a.NgbModule],declarations:[p.ProfileShareComponent],providers:[{provide:l.SettingsTabProvider,useClass:c.ProfileShareSettingsProvider,multi:!0}]})],d),t.default=d},977(e,t,r){"use strict";var o=this&&this.__decorate||function(e,t,r,o){var s,n=arguments.length,i=n<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)i=Reflect.decorate(e,t,r,o);else for(var a=e.length-1;a>=0;a--)(s=e[a])&&(i=(n<3?s(i):n>3?s(t,r,i):s(t,r))||i);return n>3&&i&&Object.defineProperty(t,r,i),i};Object.defineProperty(t,"__esModule",{value:!0}),t.ProfileShareSettingsProvider=void 0;const s=r(430),n=r(262),i=r(207);let a=class extends n.SettingsTabProvider{constructor(){super(...arguments),this.id="profile-share",this.title="Profile Sharing",this.icon="share-alt",this.weight=200}getComponentType(){return i.ProfileShareComponent}};a=o([(0,s.Injectable)()],a),t.ProfileShareSettingsProvider=a},207(e,t,r){"use strict";var o=this&&this.__decorate||function(e,t,r,o){var s,n=arguments.length,i=n<3?t:null===o?o=Object.getOwnPropertyDescriptor(t,r):o;if("object"==typeof Reflect&&"function"==typeof Reflect.decorate)i=Reflect.decorate(e,t,r,o);else for(var a=e.length-1;a>=0;a--)(s=e[a])&&(i=(n<3?s(i):n>3?s(t,r,i):s(t,r))||i);return n>3&&i&&Object.defineProperty(t,r,i),i},s=this&&this.__metadata||function(e,t){if("object"==typeof Reflect&&"function"==typeof Reflect.metadata)return Reflect.metadata(e,t)};Object.defineProperty(t,"__esModule",{value:!0}),t.ProfileShareComponent=void 0;const n=r(430),i=r(643),a=r(368),l=r(232);let c=class{constructor(e,t,r){this.profilesService=e,this.config=t,this.modal=r,this.tab="export",this.profiles=[],this.groups=[],this.tree=[],this.users=[],this.selectedIds=new Set,this.sharedUsers=new Set,this.imported=null,this.importFileName="",this.importSummary="",this.error="",this.info="",this.busy=!1,this.preview=""}async ngOnInit(){await this.reload()}async reload(){this.error="";const e=await this.profilesService.getProfiles({includeBuiltin:!1,clone:!0});this.profiles=e.filter(e=>"ssh"===e.type&&!e.isTemplate&&!e.isBuiltin),this.groups=this.profilesService.getSyncProfileGroups();const t=new Map,r=new Set(this.groups.map(e=>e.id));for(const e of this.groups){const o=e.parentGroupId,s=o&&r.has(o)?o:"";t.set(s,[...t.get(s)||[],e])}const o=(e,r,s)=>(t.get(e)||[]).filter(e=>!s.has(e.id)).map(e=>{const t=o(e.id,r+1,new Set([...s,e.id])),n=this.profiles.filter(t=>t.group===e.id);return{id:e.id,name:e.name,level:r,count:n.length+t.reduce((e,t)=>e+t.count,0),expanded:r<2,selected:!1,partial:!1,children:t,profiles:n}});this.tree=o("",0,new Set);const s=this.profiles.filter(e=>!e.group||!r.has(e.group));s.length&&this.tree.push({id:"__ungrouped__",name:"Ungrouped",level:0,count:s.length,expanded:!0,selected:!1,partial:!1,children:[],profiles:s}),this.selectedIds.clear(),this.sharedUsers.clear(),this.updateUsers()}visible(e=this.tree){const t=[],r=e=>{if(t.push(e),e.expanded)for(const t of e.children)r(t)};return e.forEach(r),t}setNode(e,t){const r=e=>{for(const r of e.profiles)t?this.selectedIds.add(r.id):this.selectedIds.delete(r.id);e.children.forEach(r)};r(e),this.refreshTree()}setProfile(e,t){t?this.selectedIds.add(e.id):this.selectedIds.delete(e.id),this.refreshTree()}refreshTree(){const e=t=>{t.children.forEach(e);const r=t.profiles.length,o=t.profiles.filter(e=>this.selectedIds.has(e.id)).length,s=t.children.filter(e=>e.selected).length;t.selected=o===r&&s===t.children.length&&r+t.children.length>0,t.partial=!t.selected&&(o>0||s>0||t.children.some(e=>e.partial))};this.tree.forEach(e),this.updateUsers()}get chosen(){return this.profiles.filter(e=>this.selectedIds.has(e.id))}updateUsers(){this.users=l.uniqueUsers(this.chosen);const e=new Set(this.users.map(e=>e.user));for(const t of this.sharedUsers)e.has(t)||this.sharedUsers.delete(t)}toggleUser(e,t){t?this.sharedUsers.add(e):this.sharedUsers.delete(e)}selectAll(e){this.selectedIds=new Set(e?this.profiles.map(e=>e.id):[]),this.refreshTree()}async export(){this.error="",this.info="";try{const e=l.createPackage(this.profiles,this.groups,this.selectedIds,this.sharedUsers),t=JSON.stringify(e,null,2),r=new Blob([t],{type:"application/json"}),o=URL.createObjectURL(r),s=document.createElement("a");s.href=o,s.download="tabby-profiles.tabby-profiles.json",document.body.appendChild(s),s.click(),s.remove(),setTimeout(()=>URL.revokeObjectURL(o),1e3),this.info="Export prepared: "+e.profiles.length+" SSH profiles, "+e.groups.length+" folders."}catch(e){this.error=String(e.message||e)}}async onFile(e){var t;this.error="",this.info="",this.imported=null;const r=e.target,o=null===(t=r.files)||void 0===t?void 0:t[0];if(o){try{if(o.size>l.MAX_BYTES)throw new Error("File exceeds 5 MB");const e=l.parsePackage(await o.text());this.imported=e,this.importFileName=o.name;const t=e.profiles.filter(e=>!e.options.user);this.importSummary=e.groups.length+" new folders, "+e.profiles.length+" new profiles. "+t.length+" need SSH username."}catch(e){this.error=String(e.message||e)}r.value=""}}async import(){if(this.imported&&!this.busy){this.busy=!0,this.error="",this.info="";try{const e=this.imported.profiles.filter(e=>!e.options.user);let t="";if(e.length){const r=e.slice(0,4).map(e=>e.name+" ("+e.options.host+")").join(", "),o=e.length>4?", and "+(e.length-4)+" more":"",s=this.modal.open(a.PromptModalComponent);s.componentInstance.password=!1,s.componentInstance.prompt="SSH username for "+e.length+" devices: "+r+o+". Leave blank to keep empty.";const n=await s.result.catch(()=>null);if(!n)return void(this.info="Import cancelled.");if(t="string"==typeof n.value?n.value.trim():"",t.length>255)throw new Error("SSH username too long")}const r=l.parsePackage(JSON.stringify(this.imported)),o=()=>"share-"+Date.now().toString(36)+"-"+Math.random().toString(36).slice(2)+"-"+Math.random().toString(36).slice(2),s=l.buildImportPlan(r,t,o),n=[...this.config.store.groups],i=[...this.config.store.profiles];try{this.config.store.groups.push(...s.groups),this.config.store.profiles.push(...s.profiles),await this.config.save()}catch(e){this.config.store.groups=n,this.config.store.profiles=i;try{await this.config.save()}catch{}throw e}this.info="Imported "+s.profiles.length+" new profiles and "+s.groups.length+" folders. Existing entries were preserved.",this.imported=null,await this.reload()}catch(e){this.error=String(e.message||e)}finally{this.busy=!1}}}};c=o([(0,n.Component)({template:r(247)}),s("design:paramtypes",[a.ProfilesService,a.ConfigService,i.NgbModal])],c),t.ProfileShareComponent=c},232(e){"use strict";const t="tabby-profile-share",r=5242880,o=["hmac","kex","cipher","serverHostKey","compression"],s=[null,"password","publicKey","agent","keyboardInteractive"],n=new Set(["host","port","user","auth","algorithms","jumpHost","keepaliveInterval","keepaliveCountMax","readyTimeout","x11","skipBanner","reuseSession","warnOnClose","term"]),i=new Set(["id","type","name","group","icon","color","options"]),a=new Set(["id","name","parentGroupId","icon","color"]);function l(e){return!!e&&"object"==typeof e&&!Array.isArray(e)&&Object.getPrototypeOf(e)===Object.prototype}function c(e,t=512){return"string"==typeof e&&e.length<=t}function p(e,t,r){return Number.isInteger(e)&&e>=t&&e<=r}function d(e,t){if(!e)throw new Error(t)}function u(e,t=512){return null==e||c(e,t)}function f(e,t,r,o){Object.prototype.hasOwnProperty.call(e,r)&&o(e[r])&&(t[r]=e[r])}function h(e,t){const r={};f(e=l(e)?e:{},r,"host",e=>c(e,255)&&!!e.trim()),f(e,r,"port",e=>p(e,1,65535)),r.user=t&&c(e.user,255)?e.user:"",f(e,r,"auth",e=>s.includes(e));for(const t of["keepaliveInterval","keepaliveCountMax","readyTimeout"])f(e,r,t,e=>null===e||p(e,0,2147483647));for(const t of["x11","skipBanner","reuseSession"])f(e,r,t,e=>"boolean"==typeof e);if(f(e,r,"warnOnClose",e=>null===e||"boolean"==typeof e),f(e,r,"term",e=>c(e,80)),l(e.algorithms)){const t={};for(const r of o)Array.isArray(e.algorithms[r])&&(t[r]=e.algorithms[r].filter(e=>c(e,128)&&/^[A-Za-z0-9@_.+,-]+$/.test(e)).slice(0,64));Object.keys(t).length&&(r.algorithms=t)}return r}e.exports={MAX_BYTES:r,uniqueUsers:function(e){const t=new Map;for(const r of e){const e=r.options?.user;"string"==typeof e&&e.trim()&&t.set(e,(t.get(e)||0)+1)}return Array.from(t,([e,t])=>({user:e,count:t})).sort((e,t)=>e.user.localeCompare(t.user))},createPackage:function(e,r,o,s){d(Array.isArray(e)&&Array.isArray(r),"Invalid Tabby data");const n=e.filter(e=>"ssh"===e.type&&o.has(e.id));d(n.length>0,"Select at least one SSH profile"),d(n.length<=1e4,"Too many profiles");const i=new Map(r.map(e=>[e.id,e])),a=new Set;for(const e of n){let t=e.group;const r=new Set;for(;t&&i.has(t);)d(!r.has(t),"A folder cycle was found"),r.add(t),a.add(t),t=i.get(t).parentGroupId}d(a.size<=4e3,"Too many folders");const l=r.filter(e=>a.has(e.id)),p=new Map(l.map((e,t)=>[e.id,"g"+(t+1)])),u=new Map(n.map((e,t)=>[e.id,"p"+(t+1)])),f=l.map(e=>{d(c(e.name)&&!!e.name.trim(),"Invalid folder name");const t={id:p.get(e.id),name:e.name};return p.has(e.parentGroupId)&&(t.parentGroupId=p.get(e.parentGroupId)),c(e.icon,128)&&(t.icon=e.icon),c(e.color,128)&&(t.color=e.color),t}),m=n.map(e=>{d(c(e.name)&&!!e.name.trim(),"Invalid profile name");const t=h(e.options,s.has(e.options?.user));d(t.host,"SSH host is missing: "+e.name),u.has(e.options?.jumpHost)&&(t.jumpHost=u.get(e.options.jumpHost));const r={id:u.get(e.id),type:"ssh",name:e.name,options:t};return p.has(e.group)&&(r.group=p.get(e.group)),c(e.icon,128)&&(r.icon=e.icon),c(e.color,128)&&(r.color=e.color),r});return{format:t,formatVersion:1,groups:f,profiles:m}},parsePackage:function(e){let o;d("string"==typeof e&&e.length<=r,"File exceeds 5 MB or is not text");try{o=JSON.parse(e)}catch{throw new Error("Invalid JSON file")}d(l(o)&&o.format===t&&1===o.formatVersion,"Unsupported Profile Share format/version"),d(Array.isArray(o.groups)&&o.groups.length<=4e3,"Invalid folder count"),d(Array.isArray(o.profiles)&&o.profiles.length>0&&o.profiles.length<=1e4,"Invalid SSH profile count");const s=new Set;for(const e of o.groups)d(l(e)&&Object.keys(e).every(e=>a.has(e)),"Unknown folder field"),d(c(e.id,100)&&!!e.id&&!s.has(e.id),"Duplicate/invalid folder ID"),d(c(e.name)&&!!e.name.trim(),"Invalid folder name"),d(u(e.parentGroupId,100)&&u(e.icon,128)&&u(e.color,128),"Invalid folder metadata"),s.add(e.id);const p=new Map(o.groups.map(e=>[e.id,e]));for(const e of o.groups){void 0!==e.parentGroupId&&d(s.has(e.parentGroupId),"Folder parent missing");const t=new Set;let r=e;for(;r;)d(!t.has(r.id),"Folder cycle detected"),t.add(r.id),r=p.get(r.parentGroupId)}const f=new Set;for(const e of o.profiles){d(l(e)&&Object.keys(e).every(e=>i.has(e)),"Unknown profile field"),d(c(e.id,100)&&!!e.id&&!f.has(e.id),"Duplicate/invalid profile ID"),d("ssh"===e.type&&c(e.name)&&!!e.name.trim(),"Only SSH profiles are supported"),d(void 0===e.group||s.has(e.group),"Profile folder missing"),d(u(e.icon,128)&&u(e.color,128),"Invalid profile metadata"),d(l(e.options)&&Object.keys(e.options).every(e=>n.has(e)),"Unsafe/unknown SSH option"),d(c(e.options.host,255)&&!!e.options.host.trim(),"Invalid SSH hostname"),d(void 0===e.options.user||c(e.options.user,255),"Invalid SSH username");const t=h(e.options,!0);for(const r of Object.keys(e.options))"jumpHost"!==r&&d(JSON.stringify(e.options[r])===JSON.stringify(t[r]),"Invalid SSH option: "+r);void 0!==e.options.jumpHost&&d(c(e.options.jumpHost,100),"Invalid jump-host ID"),f.add(e.id)}for(const e of o.profiles)e.options.jumpHost&&d(f.has(e.options.jumpHost),"Referenced jump host not included");return o},buildImportPlan:function(e,t,r){d(l(e)&&"function"==typeof r,"Invalid import plan");const o=new Map(e.groups.map(e=>[e.id,r()])),s=new Map(e.profiles.map(e=>[e.id,"ssh:custom:shared:"+r()]));return{groups:e.groups.map(e=>({id:o.get(e.id),name:e.name,...e.parentGroupId?{parentGroupId:o.get(e.parentGroupId)}:{},...e.icon?{icon:e.icon}:{},...e.color?{color:e.color}:{}})),profiles:e.profiles.map(e=>{const r=h(e.options,!0);return!r.user&&t&&(r.user=t),e.options.jumpHost&&(r.jumpHost=s.get(e.options.jumpHost)),{id:s.get(e.id),type:"ssh",name:e.name,...e.group?{group:o.get(e.group)}:{},...e.icon?{icon:e.icon}:{},...e.color?{color:e.color}:{},options:r}})}}}},804(e){"use strict";e.exports=require("@angular/common")},430(e){"use strict";e.exports=require("@angular/core")},440(e){"use strict";e.exports=require("@angular/forms")},643(e){"use strict";e.exports=require("@ng-bootstrap/ng-bootstrap")},21(e){"use strict";e.exports=require("fs")},368(e){"use strict";e.exports=require("tabby-core")},262(e){"use strict";e.exports=require("tabby-settings")}};const t={};let r=function r(o){const s=t[o];if(void 0!==s)return s.exports;const n=t[o]={exports:{}};return e[o].call(n.exports,n,n.exports,r),n.exports}(729);module.exports=r})();
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import { NgbModal } from '@ng-bootstrap/ng-bootstrap';
|
|
2
|
+
import { ConfigService, ProfilesService, PartialProfile, Profile, ProfileGroup, PartialProfileGroup } from 'tabby-core';
|
|
3
|
+
interface Node {
|
|
4
|
+
id: string;
|
|
5
|
+
name: string;
|
|
6
|
+
level: number;
|
|
7
|
+
count: number;
|
|
8
|
+
expanded: boolean;
|
|
9
|
+
selected: boolean;
|
|
10
|
+
partial: boolean;
|
|
11
|
+
children: Node[];
|
|
12
|
+
profiles: PartialProfile<Profile>[];
|
|
13
|
+
}
|
|
14
|
+
export declare class ProfileShareComponent {
|
|
15
|
+
private profilesService;
|
|
16
|
+
private config;
|
|
17
|
+
private modal;
|
|
18
|
+
tab: 'export' | 'import';
|
|
19
|
+
profiles: PartialProfile<Profile>[];
|
|
20
|
+
groups: PartialProfileGroup<ProfileGroup>[];
|
|
21
|
+
tree: Node[];
|
|
22
|
+
users: Array<{
|
|
23
|
+
user: string;
|
|
24
|
+
count: number;
|
|
25
|
+
}>;
|
|
26
|
+
selectedIds: Set<string>;
|
|
27
|
+
sharedUsers: Set<string>;
|
|
28
|
+
imported: any;
|
|
29
|
+
importFileName: string;
|
|
30
|
+
importSummary: string;
|
|
31
|
+
error: string;
|
|
32
|
+
info: string;
|
|
33
|
+
busy: boolean;
|
|
34
|
+
preview: string;
|
|
35
|
+
constructor(profilesService: ProfilesService, config: ConfigService, modal: NgbModal);
|
|
36
|
+
ngOnInit(): Promise<void>;
|
|
37
|
+
reload(): Promise<void>;
|
|
38
|
+
visible(nodes?: Node[]): Node[];
|
|
39
|
+
setNode(node: Node, checked: boolean): void;
|
|
40
|
+
setProfile(profile: PartialProfile<Profile>, checked: boolean): void;
|
|
41
|
+
refreshTree(): void;
|
|
42
|
+
get chosen(): PartialProfile<Profile>[];
|
|
43
|
+
updateUsers(): void;
|
|
44
|
+
toggleUser(user: string, checked: boolean): void;
|
|
45
|
+
selectAll(enabled: boolean): void;
|
|
46
|
+
export(): Promise<void>;
|
|
47
|
+
onFile(event: Event): Promise<void>;
|
|
48
|
+
import(): Promise<void>;
|
|
49
|
+
}
|
|
50
|
+
export {};
|
package/package.json
CHANGED
|
@@ -1,6 +1,64 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "tabby-profile-share",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"
|
|
5
|
-
"
|
|
6
|
-
|
|
3
|
+
"version": "0.1.2",
|
|
4
|
+
"description": "Safe SSH profile sharing plugin for official Eugeny Tabby",
|
|
5
|
+
"keywords": [
|
|
6
|
+
"tabby-plugin",
|
|
7
|
+
"tabby",
|
|
8
|
+
"ssh",
|
|
9
|
+
"profiles"
|
|
10
|
+
],
|
|
11
|
+
"main": "dist/index.js",
|
|
12
|
+
"types": "dist/index.d.ts",
|
|
13
|
+
"scripts": {
|
|
14
|
+
"build": "webpack --mode production",
|
|
15
|
+
"build:dev": "webpack --mode development",
|
|
16
|
+
"test": "node --test test/*.test.js",
|
|
17
|
+
"check": "npm run test && npm run build",
|
|
18
|
+
"prepublishOnly": "npm run check"
|
|
19
|
+
},
|
|
20
|
+
"files": [
|
|
21
|
+
"dist",
|
|
22
|
+
"README.md",
|
|
23
|
+
"README.pt-BR.md",
|
|
24
|
+
"LICENSE",
|
|
25
|
+
"CHANGELOG.md"
|
|
26
|
+
],
|
|
27
|
+
"peerDependencies": {
|
|
28
|
+
"@angular/core": "^15.0.0",
|
|
29
|
+
"@angular/common": "^15.0.0",
|
|
30
|
+
"@angular/forms": "^15.0.0",
|
|
31
|
+
"@ng-bootstrap/ng-bootstrap": "^14.0.0",
|
|
32
|
+
"tabby-core": "*",
|
|
33
|
+
"tabby-settings": "*"
|
|
34
|
+
},
|
|
35
|
+
"devDependencies": {
|
|
36
|
+
"@angular/core": "^15.0.0",
|
|
37
|
+
"@angular/common": "^15.0.0",
|
|
38
|
+
"@angular/forms": "^15.0.0",
|
|
39
|
+
"@ng-bootstrap/ng-bootstrap": "^14.0.0",
|
|
40
|
+
"@types/node": "^18.0.0",
|
|
41
|
+
"apply-loader": "^2.0.0",
|
|
42
|
+
"pug": "^3.0.0",
|
|
43
|
+
"pug-loader": "^2.4.0",
|
|
44
|
+
"ts-loader": "^9.5.0",
|
|
45
|
+
"typescript": "~4.9.5",
|
|
46
|
+
"webpack": "^5.90.0",
|
|
47
|
+
"webpack-cli": "^5.1.0",
|
|
48
|
+
"tabby-core": "^1.0.231-nightly.0",
|
|
49
|
+
"tabby-settings": "^1.0.231-nightly.0"
|
|
50
|
+
},
|
|
51
|
+
"author": "gugahartmann",
|
|
52
|
+
"repository": {
|
|
53
|
+
"type": "git",
|
|
54
|
+
"url": "https://github.com/gugahartmann/Tabby_Profile_Share.git"
|
|
55
|
+
},
|
|
56
|
+
"homepage": "https://github.com/gugahartmann/Tabby_Profile_Share#readme",
|
|
57
|
+
"license": "MIT",
|
|
58
|
+
"bugs": {
|
|
59
|
+
"url": "https://github.com/gugahartmann/Tabby_Profile_Share/issues"
|
|
60
|
+
},
|
|
61
|
+
"publishConfig": {
|
|
62
|
+
"access": "public"
|
|
63
|
+
}
|
|
64
|
+
}
|