@inwink-mcp/backoffice-extension-helpers 1.0.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/README.md +96 -0
- package/dist/cli.js +500 -0
- package/package.json +36 -0
- package/templates/test-app/src/pages/HomePage.tsx.tpl +55 -0
- package/templates/vite-extension/AGENTS.md.tpl +132 -0
- package/templates/vite-extension/CLAUDE.md +5 -0
- package/templates/vite-extension/extension.manifest.json.tpl +18 -0
- package/templates/vite-extension/index.html.tpl +12 -0
- package/templates/vite-extension/package.json.tpl +29 -0
- package/templates/vite-extension/scripts/zip-build.mjs +36 -0
- package/templates/vite-extension/src/App.tsx.tpl +23 -0
- package/templates/vite-extension/src/api/endpoints.ts.tpl +5 -0
- package/templates/vite-extension/src/auth/AuthProvider.tsx +80 -0
- package/templates/vite-extension/src/auth/ProtectedRoute.tsx +12 -0
- package/templates/vite-extension/src/auth/config.ts +16 -0
- package/templates/vite-extension/src/hooks/useAuth.ts +12 -0
- package/templates/vite-extension/src/hooks/useInwinkApi.ts +101 -0
- package/templates/vite-extension/src/hooks/useInwinkCommunityApi.ts +7 -0
- package/templates/vite-extension/src/hooks/useInwinkCustomerApi.ts +7 -0
- package/templates/vite-extension/src/hooks/useInwinkEventApi.ts +7 -0
- package/templates/vite-extension/src/main.tsx +16 -0
- package/templates/vite-extension/src/pages/HomePage.tsx.tpl +26 -0
- package/templates/vite-extension/src/styles.css +32 -0
- package/templates/vite-extension/src/vite-env.d.ts +1 -0
- package/templates/vite-extension/tsconfig.json +20 -0
- package/templates/vite-extension/tsconfig.node.json +15 -0
- package/templates/vite-extension/vite.config.ts +30 -0
package/README.md
ADDED
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
# @inwink-mcp/backoffice-extension-helpers
|
|
2
|
+
|
|
3
|
+
CLI pour **bootstrapper**, **packager** et **vérifier** les extensions backoffice inwink.
|
|
4
|
+
|
|
5
|
+
Une extension inwink est une SPA React/TypeScript 100 % client-side, qui s'authentifie en OIDC (Authorization Code + PKCE) et appelle les API inwink en Bearer token. Cette CLI génère le squelette conforme (auth, config Vite, manifest, packaging) pour éviter de le réécrire à la main, puis automatise le build + zip + contrôle de conformité.
|
|
6
|
+
|
|
7
|
+
S'utilise via `npx`, sans installation :
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
npx @inwink-mcp/backoffice-extension-helpers@latest <commande> [options]
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
## Commandes
|
|
14
|
+
|
|
15
|
+
### `bootstrap`
|
|
16
|
+
|
|
17
|
+
Génère le squelette d'une extension dans le dossier courant (ou `--dir`).
|
|
18
|
+
|
|
19
|
+
```bash
|
|
20
|
+
npx @inwink-mcp/backoffice-extension-helpers@latest bootstrap \
|
|
21
|
+
--name mon-extension \
|
|
22
|
+
--display-name "Mon Extension" \
|
|
23
|
+
--description "Ce que fait l'extension." \
|
|
24
|
+
--author moi@inwink.com \
|
|
25
|
+
--event-api-url https://.../event \
|
|
26
|
+
--community-api-url https://.../community \
|
|
27
|
+
--customer-api-url https://.../customer
|
|
28
|
+
```
|
|
29
|
+
|
|
30
|
+
| Option | Description |
|
|
31
|
+
|--------|-------------|
|
|
32
|
+
| `--name <slug>` | **(requis)** Slug unique (minuscules, chiffres, tirets) |
|
|
33
|
+
| `--display-name <name>` | **(requis)** Nom affiché dans l'interface inwink |
|
|
34
|
+
| `--description <text>` | **(requis)** Description courte |
|
|
35
|
+
| `--author <email>` | **(requis)** Email de l'auteur |
|
|
36
|
+
| `--event-api-url <url>` | **(requis)** URL de base de l'API Event |
|
|
37
|
+
| `--community-api-url <url>` | **(requis)** URL de base de l'API Community |
|
|
38
|
+
| `--customer-api-url <url>` | **(requis)** URL de base de l'API Customer |
|
|
39
|
+
| `--template <id>` | Template à utiliser (défaut : `vite-extension`) |
|
|
40
|
+
| `--dir <path>` | Dossier cible (défaut : dossier courant) |
|
|
41
|
+
| `--force` | Écrire même si le dossier cible n'est pas vide |
|
|
42
|
+
| `--install` | Lancer `npm install` après la génération |
|
|
43
|
+
|
|
44
|
+
Fichiers générés : `extension.manifest.json`, `package.json`, `vite.config.ts`, `tsconfig*.json`, `index.html`, `src/` (auth OIDC, `api/endpoints.ts`, hooks `useInwink*Api`, `main.tsx`, `App.tsx`), `scripts/zip-build.mjs`, ainsi que `AGENTS.md` et `CLAUDE.md` pour guider les agents IA. Les URLs d'API sont injectées dans `src/api/endpoints.ts`.
|
|
45
|
+
|
|
46
|
+
### `package`
|
|
47
|
+
|
|
48
|
+
Build + zip `{name}-{version}.zip` + vérification de conformité, en une commande.
|
|
49
|
+
|
|
50
|
+
```bash
|
|
51
|
+
npx @inwink-mcp/backoffice-extension-helpers@latest package
|
|
52
|
+
```
|
|
53
|
+
|
|
54
|
+
| Option | Description |
|
|
55
|
+
|--------|-------------|
|
|
56
|
+
| `--dir <path>` | Dossier du projet (défaut : dossier courant) |
|
|
57
|
+
|
|
58
|
+
Lance `npm run build` dans le projet, zippe le **contenu** de `dist/`, puis vérifie la conformité. Sort en code ≠ 0 si la conformité échoue.
|
|
59
|
+
|
|
60
|
+
### `verify`
|
|
61
|
+
|
|
62
|
+
Vérifie la conformité **sans** rebuild (utile en CI ou après un build manuel).
|
|
63
|
+
|
|
64
|
+
```bash
|
|
65
|
+
npx @inwink-mcp/backoffice-extension-helpers@latest verify
|
|
66
|
+
```
|
|
67
|
+
|
|
68
|
+
| Option | Description |
|
|
69
|
+
|--------|-------------|
|
|
70
|
+
| `--dir <path>` | Dossier du projet (défaut : dossier courant) |
|
|
71
|
+
| `--zip <file>` | Chemin d'un zip à inspecter |
|
|
72
|
+
|
|
73
|
+
Contrôles effectués :
|
|
74
|
+
|
|
75
|
+
- `extension.manifest.json` présent, `name` (slug) et `version` (semver) valides ;
|
|
76
|
+
- `dist/` contient `index.html` + `extension.manifest.json` à la racine + `assets/` ;
|
|
77
|
+
- le zip contient `index.html` et `extension.manifest.json` à la racine, sans `node_modules/`, `src/` ni `dist/` ;
|
|
78
|
+
- avertissements : `base: './'` absent de `vite.config.ts`, usage de `BrowserRouter`.
|
|
79
|
+
|
|
80
|
+
## Templates
|
|
81
|
+
|
|
82
|
+
Les templates sont des sous-dossiers de [`templates/`](templates/). Le seul template actuel est [`vite-extension`](templates/vite-extension/). En ajouter d'autres : créer un nouveau sous-dossier (les fichiers `.tpl` voient leurs placeholders `__NAME__`, `__DISPLAY_NAME__`, `__DESCRIPTION__`, `__AUTHOR__` substitués et leur suffixe `.tpl` retiré ; les autres fichiers sont copiés tels quels).
|
|
83
|
+
|
|
84
|
+
## Développement
|
|
85
|
+
|
|
86
|
+
```bash
|
|
87
|
+
npm install
|
|
88
|
+
npm run build # compile src/ → dist/cli.js (tsup, ESM, shebang)
|
|
89
|
+
npm run dev # build en watch
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Test local rapide :
|
|
93
|
+
|
|
94
|
+
```bash
|
|
95
|
+
node dist/cli.js bootstrap --name demo --display-name "Demo" --description "..." --author a@b.com --dir /tmp/demo
|
|
96
|
+
```
|
package/dist/cli.js
ADDED
|
@@ -0,0 +1,500 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// src/cli.ts
|
|
4
|
+
import { Command } from "commander";
|
|
5
|
+
|
|
6
|
+
// src/commands/apply.ts
|
|
7
|
+
import path3 from "path";
|
|
8
|
+
import pc2 from "picocolors";
|
|
9
|
+
|
|
10
|
+
// src/lib/log.ts
|
|
11
|
+
import pc from "picocolors";
|
|
12
|
+
function info(message) {
|
|
13
|
+
process.stdout.write(`${message}
|
|
14
|
+
`);
|
|
15
|
+
}
|
|
16
|
+
function success(message) {
|
|
17
|
+
process.stdout.write(`${pc.green("\u2713")} ${message}
|
|
18
|
+
`);
|
|
19
|
+
}
|
|
20
|
+
function warn(message) {
|
|
21
|
+
process.stderr.write(`${pc.yellow("\u26A0")} ${message}
|
|
22
|
+
`);
|
|
23
|
+
}
|
|
24
|
+
function error(message) {
|
|
25
|
+
process.stderr.write(`${pc.red("\u2717")} ${message}
|
|
26
|
+
`);
|
|
27
|
+
}
|
|
28
|
+
function printVerifyResult(result) {
|
|
29
|
+
for (const w of result.warnings) warn(w);
|
|
30
|
+
for (const e of result.errors) error(e);
|
|
31
|
+
if (result.errors.length === 0) {
|
|
32
|
+
success(
|
|
33
|
+
result.warnings.length > 0 ? `Conformit\xE9 OK (${result.warnings.length} avertissement(s)).` : "Conformit\xE9 OK."
|
|
34
|
+
);
|
|
35
|
+
return true;
|
|
36
|
+
}
|
|
37
|
+
error(`Conformit\xE9 \xC9CHOU\xC9E : ${result.errors.length} erreur(s).`);
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// src/lib/manifest.ts
|
|
42
|
+
import fs from "fs";
|
|
43
|
+
import path from "path";
|
|
44
|
+
var MANIFEST_FILENAME = "extension.manifest.json";
|
|
45
|
+
var SLUG_RE = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
46
|
+
var SEMVER_RE = /^\d+\.\d+\.\d+(?:-[0-9A-Za-z-.]+)?(?:\+[0-9A-Za-z-.]+)?$/;
|
|
47
|
+
function isValidSlug(value) {
|
|
48
|
+
return SLUG_RE.test(value);
|
|
49
|
+
}
|
|
50
|
+
function isValidSemver(value) {
|
|
51
|
+
return SEMVER_RE.test(value);
|
|
52
|
+
}
|
|
53
|
+
function manifestPath(projectDir) {
|
|
54
|
+
return path.join(projectDir, MANIFEST_FILENAME);
|
|
55
|
+
}
|
|
56
|
+
function readManifest(projectDir) {
|
|
57
|
+
const file = manifestPath(projectDir);
|
|
58
|
+
if (!fs.existsSync(file)) {
|
|
59
|
+
throw new Error(`${MANIFEST_FILENAME} introuvable \xE0 la racine du projet (${projectDir}).`);
|
|
60
|
+
}
|
|
61
|
+
let raw;
|
|
62
|
+
try {
|
|
63
|
+
raw = fs.readFileSync(file, "utf8");
|
|
64
|
+
} catch (err) {
|
|
65
|
+
throw new Error(`Impossible de lire ${MANIFEST_FILENAME} : ${err.message}`);
|
|
66
|
+
}
|
|
67
|
+
try {
|
|
68
|
+
return JSON.parse(raw);
|
|
69
|
+
} catch (err) {
|
|
70
|
+
throw new Error(`${MANIFEST_FILENAME} n'est pas un JSON valide : ${err.message}`);
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
function getNameAndVersion(manifest) {
|
|
74
|
+
const { name, version } = manifest;
|
|
75
|
+
if (typeof name !== "string" || !name.trim()) {
|
|
76
|
+
throw new Error(`${MANIFEST_FILENAME} doit d\xE9finir un "name" non vide.`);
|
|
77
|
+
}
|
|
78
|
+
if (typeof version !== "string" || !version.trim()) {
|
|
79
|
+
throw new Error(`${MANIFEST_FILENAME} doit d\xE9finir une "version" non vide.`);
|
|
80
|
+
}
|
|
81
|
+
return { name, version };
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
// src/lib/templates.ts
|
|
85
|
+
import fs2 from "fs";
|
|
86
|
+
import path2 from "path";
|
|
87
|
+
import { fileURLToPath } from "url";
|
|
88
|
+
var here = path2.dirname(fileURLToPath(import.meta.url));
|
|
89
|
+
var packageRoot = path2.resolve(here, "..");
|
|
90
|
+
var templatesRoot = path2.join(packageRoot, "templates");
|
|
91
|
+
function listTemplates() {
|
|
92
|
+
if (!fs2.existsSync(templatesRoot)) return [];
|
|
93
|
+
return fs2.readdirSync(templatesRoot, { withFileTypes: true }).filter((entry) => entry.isDirectory()).map((entry) => entry.name).sort();
|
|
94
|
+
}
|
|
95
|
+
function templateExists(id) {
|
|
96
|
+
return listTemplates().includes(id);
|
|
97
|
+
}
|
|
98
|
+
function escapeForJson(value) {
|
|
99
|
+
const serialized = JSON.stringify(value);
|
|
100
|
+
return serialized.slice(1, -1);
|
|
101
|
+
}
|
|
102
|
+
function substitute(content, ph, json) {
|
|
103
|
+
const v = (value) => json ? escapeForJson(value) : value;
|
|
104
|
+
return content.replaceAll("__NAME__", v(ph.name)).replaceAll("__DISPLAY_NAME__", v(ph.displayName)).replaceAll("__DESCRIPTION__", v(ph.description)).replaceAll("__AUTHOR__", v(ph.author)).replaceAll("__EVENT_API_URL__", v(ph.eventApiUrl)).replaceAll("__COMMUNITY_API_URL__", v(ph.communityApiUrl)).replaceAll("__CUSTOMER_API_URL__", v(ph.customerApiUrl));
|
|
105
|
+
}
|
|
106
|
+
function renderTemplate(templateId, destDir, ph) {
|
|
107
|
+
const srcRoot = path2.join(templatesRoot, templateId);
|
|
108
|
+
if (!fs2.existsSync(srcRoot)) {
|
|
109
|
+
throw new Error(`Template introuvable : ${templateId}`);
|
|
110
|
+
}
|
|
111
|
+
const written = [];
|
|
112
|
+
walk(srcRoot, srcRoot, destDir, ph, written);
|
|
113
|
+
return written.sort();
|
|
114
|
+
}
|
|
115
|
+
function walk(srcRoot, current, destRoot, ph, written) {
|
|
116
|
+
for (const entry of fs2.readdirSync(current, { withFileTypes: true })) {
|
|
117
|
+
const srcPath = path2.join(current, entry.name);
|
|
118
|
+
if (entry.isDirectory()) {
|
|
119
|
+
walk(srcRoot, srcPath, destRoot, ph, written);
|
|
120
|
+
continue;
|
|
121
|
+
}
|
|
122
|
+
const rel = path2.relative(srcRoot, srcPath);
|
|
123
|
+
const isTpl = entry.name.endsWith(".tpl");
|
|
124
|
+
const relOut = isTpl ? rel.slice(0, -".tpl".length) : rel;
|
|
125
|
+
const destPath = path2.join(destRoot, relOut);
|
|
126
|
+
fs2.mkdirSync(path2.dirname(destPath), { recursive: true });
|
|
127
|
+
if (isTpl) {
|
|
128
|
+
const content = fs2.readFileSync(srcPath, "utf8");
|
|
129
|
+
const isJson = destPath.endsWith(".json");
|
|
130
|
+
fs2.writeFileSync(destPath, substitute(content, ph, isJson));
|
|
131
|
+
} else {
|
|
132
|
+
fs2.copyFileSync(srcPath, destPath);
|
|
133
|
+
}
|
|
134
|
+
written.push(relOut.split(path2.sep).join("/"));
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
// src/commands/apply.ts
|
|
139
|
+
function apply(template, opts) {
|
|
140
|
+
const projectDir = path3.resolve(opts.dir ?? process.cwd());
|
|
141
|
+
if (!templateExists(template)) {
|
|
142
|
+
error(`Template inconnu : "${template}".`);
|
|
143
|
+
info(`Templates disponibles : ${listTemplates().join(", ") || "(aucun)"}`);
|
|
144
|
+
return 1;
|
|
145
|
+
}
|
|
146
|
+
let manifest;
|
|
147
|
+
try {
|
|
148
|
+
manifest = readManifest(projectDir);
|
|
149
|
+
} catch (err) {
|
|
150
|
+
error(`Projet cible invalide : ${err.message}`);
|
|
151
|
+
return 1;
|
|
152
|
+
}
|
|
153
|
+
const str = (value) => typeof value === "string" ? value : "";
|
|
154
|
+
const placeholders = {
|
|
155
|
+
name: str(manifest.name),
|
|
156
|
+
displayName: str(manifest.displayName),
|
|
157
|
+
description: str(manifest.description),
|
|
158
|
+
author: str(manifest.author),
|
|
159
|
+
// Les URLs d'API sont déjà figées dans le projet (src/api/endpoints.ts) ;
|
|
160
|
+
// un overlay ne les ré-injecte pas.
|
|
161
|
+
eventApiUrl: "",
|
|
162
|
+
communityApiUrl: "",
|
|
163
|
+
customerApiUrl: ""
|
|
164
|
+
};
|
|
165
|
+
let written;
|
|
166
|
+
try {
|
|
167
|
+
written = renderTemplate(template, projectDir, placeholders);
|
|
168
|
+
} catch (err) {
|
|
169
|
+
error(`\xC9chec de l'application : ${err.message}`);
|
|
170
|
+
return 1;
|
|
171
|
+
}
|
|
172
|
+
success(`Template "${template}" appliqu\xE9 sur ${projectDir}`);
|
|
173
|
+
for (const file of written) {
|
|
174
|
+
info(` ${pc2.dim("~")} ${file}`);
|
|
175
|
+
}
|
|
176
|
+
return 0;
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
// src/commands/bootstrap.ts
|
|
180
|
+
import fs3 from "fs";
|
|
181
|
+
import path4 from "path";
|
|
182
|
+
import pc3 from "picocolors";
|
|
183
|
+
|
|
184
|
+
// src/lib/run.ts
|
|
185
|
+
import { spawnSync } from "child_process";
|
|
186
|
+
function runNpm(args, cwd) {
|
|
187
|
+
const result = spawnSync("npm", args, {
|
|
188
|
+
cwd,
|
|
189
|
+
stdio: "inherit",
|
|
190
|
+
shell: true
|
|
191
|
+
});
|
|
192
|
+
if (result.error) {
|
|
193
|
+
throw new Error(`\xC9chec du lancement de "npm ${args.join(" ")}" : ${result.error.message}`);
|
|
194
|
+
}
|
|
195
|
+
return result.status ?? 1;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
// src/commands/bootstrap.ts
|
|
199
|
+
function validateApiUrl(flag, value) {
|
|
200
|
+
let url;
|
|
201
|
+
try {
|
|
202
|
+
url = new URL(value);
|
|
203
|
+
} catch {
|
|
204
|
+
return `${flag} n'est pas une URL valide : "${value}".`;
|
|
205
|
+
}
|
|
206
|
+
if (url.protocol !== "http:" && url.protocol !== "https:") {
|
|
207
|
+
return `${flag} doit \xEAtre une URL http(s) : "${value}".`;
|
|
208
|
+
}
|
|
209
|
+
return null;
|
|
210
|
+
}
|
|
211
|
+
function bootstrap(opts) {
|
|
212
|
+
const missing = [];
|
|
213
|
+
if (!opts.name) missing.push("--name");
|
|
214
|
+
if (!opts.displayName) missing.push("--display-name");
|
|
215
|
+
if (!opts.description) missing.push("--description");
|
|
216
|
+
if (!opts.author) missing.push("--author");
|
|
217
|
+
if (!opts.eventApiUrl) missing.push("--event-api-url");
|
|
218
|
+
if (!opts.communityApiUrl) missing.push("--community-api-url");
|
|
219
|
+
if (!opts.customerApiUrl) missing.push("--customer-api-url");
|
|
220
|
+
if (missing.length > 0) {
|
|
221
|
+
error(`Flags obligatoires manquants : ${missing.join(", ")}.`);
|
|
222
|
+
info('Exemple : inwink-extension bootstrap --name mon-ext --display-name "Mon Ext" --description "..." --author moi@inwink.com --event-api-url https://.../event --community-api-url https://.../community --customer-api-url https://.../customer');
|
|
223
|
+
return 1;
|
|
224
|
+
}
|
|
225
|
+
if (!isValidSlug(opts.name)) {
|
|
226
|
+
error(`--name doit \xEAtre un slug valide (minuscules, chiffres, tirets) : "${opts.name}".`);
|
|
227
|
+
return 1;
|
|
228
|
+
}
|
|
229
|
+
const urlChecks = [
|
|
230
|
+
["--event-api-url", opts.eventApiUrl],
|
|
231
|
+
["--community-api-url", opts.communityApiUrl],
|
|
232
|
+
["--customer-api-url", opts.customerApiUrl]
|
|
233
|
+
];
|
|
234
|
+
for (const [flag, value] of urlChecks) {
|
|
235
|
+
const err = validateApiUrl(flag, value);
|
|
236
|
+
if (err) {
|
|
237
|
+
error(err);
|
|
238
|
+
return 1;
|
|
239
|
+
}
|
|
240
|
+
if (value.startsWith("http://")) {
|
|
241
|
+
warn(`${flag} utilise http:// (non s\xE9curis\xE9) \u2014 privil\xE9giez https://.`);
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
if (!templateExists(opts.template)) {
|
|
245
|
+
error(`Template inconnu : "${opts.template}".`);
|
|
246
|
+
info(`Templates disponibles : ${listTemplates().join(", ") || "(aucun)"}`);
|
|
247
|
+
return 1;
|
|
248
|
+
}
|
|
249
|
+
const destDir = path4.resolve(opts.dir ?? process.cwd());
|
|
250
|
+
if (fs3.existsSync(destDir)) {
|
|
251
|
+
if (fs3.statSync(destDir).isFile()) {
|
|
252
|
+
error(`La cible existe et n'est pas un dossier : ${destDir}`);
|
|
253
|
+
return 1;
|
|
254
|
+
}
|
|
255
|
+
const entries = fs3.readdirSync(destDir);
|
|
256
|
+
if (entries.length > 0 && !opts.force) {
|
|
257
|
+
error(`Le dossier cible n'est pas vide : ${destDir}`);
|
|
258
|
+
info("Utilisez --force pour \xE9crire malgr\xE9 tout, ou --dir vers un dossier vide.");
|
|
259
|
+
return 1;
|
|
260
|
+
}
|
|
261
|
+
} else {
|
|
262
|
+
fs3.mkdirSync(destDir, { recursive: true });
|
|
263
|
+
}
|
|
264
|
+
const placeholders = {
|
|
265
|
+
name: opts.name,
|
|
266
|
+
displayName: opts.displayName,
|
|
267
|
+
description: opts.description,
|
|
268
|
+
author: opts.author,
|
|
269
|
+
eventApiUrl: opts.eventApiUrl,
|
|
270
|
+
communityApiUrl: opts.communityApiUrl,
|
|
271
|
+
customerApiUrl: opts.customerApiUrl
|
|
272
|
+
};
|
|
273
|
+
let written;
|
|
274
|
+
try {
|
|
275
|
+
written = renderTemplate(opts.template, destDir, placeholders);
|
|
276
|
+
} catch (err) {
|
|
277
|
+
error(`\xC9chec de la g\xE9n\xE9ration : ${err.message}`);
|
|
278
|
+
return 1;
|
|
279
|
+
}
|
|
280
|
+
success(`Extension "${opts.displayName}" g\xE9n\xE9r\xE9e dans ${destDir} (template: ${opts.template})`);
|
|
281
|
+
for (const file of written) {
|
|
282
|
+
info(` ${pc3.dim("+")} ${file}`);
|
|
283
|
+
}
|
|
284
|
+
info("");
|
|
285
|
+
info(pc3.dim("API configur\xE9es (src/api/endpoints.ts) :"));
|
|
286
|
+
info(` ${pc3.dim("event ")} ${opts.eventApiUrl}`);
|
|
287
|
+
info(` ${pc3.dim("community")} ${opts.communityApiUrl}`);
|
|
288
|
+
info(` ${pc3.dim("customer ")} ${opts.customerApiUrl}`);
|
|
289
|
+
if (opts.install) {
|
|
290
|
+
info("");
|
|
291
|
+
info("Installation des d\xE9pendances (npm install)\u2026");
|
|
292
|
+
const code = runNpm(["install"], destDir);
|
|
293
|
+
if (code !== 0) {
|
|
294
|
+
warn("npm install a \xE9chou\xE9 \u2014 lancez-le manuellement.");
|
|
295
|
+
return code;
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
info("");
|
|
299
|
+
info(pc3.bold("Prochaines \xE9tapes :"));
|
|
300
|
+
if (opts.dir) info(` cd ${opts.dir}`);
|
|
301
|
+
if (!opts.install) info(" npm install");
|
|
302
|
+
info(" npm run dev # d\xE9veloppement local");
|
|
303
|
+
info(" npx @inwink-mcp/backoffice-extension-helpers package # build + zip + v\xE9rification");
|
|
304
|
+
return 0;
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
// src/commands/pack.ts
|
|
308
|
+
import path7 from "path";
|
|
309
|
+
|
|
310
|
+
// src/lib/verify.ts
|
|
311
|
+
import fs4 from "fs";
|
|
312
|
+
import path5 from "path";
|
|
313
|
+
import AdmZip from "adm-zip";
|
|
314
|
+
function verifyExtension(options) {
|
|
315
|
+
const { projectDir, requireDist = false } = options;
|
|
316
|
+
const errors = [];
|
|
317
|
+
const warnings = [];
|
|
318
|
+
let name;
|
|
319
|
+
let version;
|
|
320
|
+
try {
|
|
321
|
+
const manifest = readManifest(projectDir);
|
|
322
|
+
const nv = getNameAndVersion(manifest);
|
|
323
|
+
name = nv.name;
|
|
324
|
+
version = nv.version;
|
|
325
|
+
if (!isValidSlug(name)) {
|
|
326
|
+
errors.push(`Le "name" du manifest n'est pas un slug valide (minuscules + tirets) : "${name}".`);
|
|
327
|
+
}
|
|
328
|
+
if (!isValidSemver(version)) {
|
|
329
|
+
errors.push(`La "version" du manifest n'est pas un semver valide : "${version}".`);
|
|
330
|
+
}
|
|
331
|
+
} catch (err) {
|
|
332
|
+
errors.push(err.message);
|
|
333
|
+
}
|
|
334
|
+
const distDir = path5.join(projectDir, "dist");
|
|
335
|
+
if (!fs4.existsSync(distDir)) {
|
|
336
|
+
if (requireDist) {
|
|
337
|
+
errors.push("Dossier dist/ introuvable apr\xE8s le build.");
|
|
338
|
+
}
|
|
339
|
+
} else {
|
|
340
|
+
if (!fs4.existsSync(path5.join(distDir, "index.html"))) {
|
|
341
|
+
errors.push("dist/index.html manquant.");
|
|
342
|
+
}
|
|
343
|
+
if (!fs4.existsSync(path5.join(distDir, "extension.manifest.json"))) {
|
|
344
|
+
errors.push("dist/extension.manifest.json manquant (le plugin Vite doit le copier).");
|
|
345
|
+
}
|
|
346
|
+
const assetsDir = path5.join(distDir, "assets");
|
|
347
|
+
if (!fs4.existsSync(assetsDir) || !fs4.statSync(assetsDir).isDirectory()) {
|
|
348
|
+
warnings.push("dist/assets/ introuvable \u2014 build Vite incomplet ?");
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
const zipPath = resolveZipPath(options, projectDir, name, version);
|
|
352
|
+
if (zipPath && fs4.existsSync(zipPath)) {
|
|
353
|
+
verifyZip(zipPath, errors);
|
|
354
|
+
} else if (options.zipPath) {
|
|
355
|
+
errors.push(`Zip introuvable : ${options.zipPath}`);
|
|
356
|
+
}
|
|
357
|
+
checkViteBase(projectDir, warnings);
|
|
358
|
+
checkRouter(projectDir, warnings);
|
|
359
|
+
return { errors, warnings };
|
|
360
|
+
}
|
|
361
|
+
function resolveZipPath(options, projectDir, name, version) {
|
|
362
|
+
if (options.zipPath) {
|
|
363
|
+
return path5.isAbsolute(options.zipPath) ? options.zipPath : path5.join(projectDir, options.zipPath);
|
|
364
|
+
}
|
|
365
|
+
if (name && version) {
|
|
366
|
+
const candidate = path5.join(projectDir, `${name}-${version}.zip`);
|
|
367
|
+
return fs4.existsSync(candidate) ? candidate : void 0;
|
|
368
|
+
}
|
|
369
|
+
return void 0;
|
|
370
|
+
}
|
|
371
|
+
function verifyZip(zipPath, errors) {
|
|
372
|
+
let entries;
|
|
373
|
+
try {
|
|
374
|
+
entries = new AdmZip(zipPath).getEntries();
|
|
375
|
+
} catch (err) {
|
|
376
|
+
errors.push(`Impossible de lire le zip ${path5.basename(zipPath)} : ${err.message}`);
|
|
377
|
+
return;
|
|
378
|
+
}
|
|
379
|
+
const names = entries.map((e) => e.entryName.split("\\").join("/"));
|
|
380
|
+
if (!names.includes("index.html")) {
|
|
381
|
+
errors.push("Le zip ne contient pas index.html \xE0 la racine.");
|
|
382
|
+
}
|
|
383
|
+
if (!names.includes("extension.manifest.json")) {
|
|
384
|
+
errors.push("Le zip ne contient pas extension.manifest.json \xE0 la racine.");
|
|
385
|
+
}
|
|
386
|
+
if (names.some((n) => n.startsWith("node_modules/"))) {
|
|
387
|
+
errors.push("Le zip contient un dossier node_modules/ (interdit).");
|
|
388
|
+
}
|
|
389
|
+
if (names.some((n) => n.startsWith("src/"))) {
|
|
390
|
+
errors.push("Le zip contient un dossier src/ (le code source ne doit pas \xEAtre packag\xE9).");
|
|
391
|
+
}
|
|
392
|
+
if (names.some((n) => n.startsWith("dist/"))) {
|
|
393
|
+
errors.push("Le zip contient un dossier dist/ : zippez le CONTENU de dist/, pas le dossier.");
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
function checkViteBase(projectDir, warnings) {
|
|
397
|
+
const file = path5.join(projectDir, "vite.config.ts");
|
|
398
|
+
if (!fs4.existsSync(file)) return;
|
|
399
|
+
const content = fs4.readFileSync(file, "utf8");
|
|
400
|
+
if (!/base:\s*['"]\.\/['"]/.test(content)) {
|
|
401
|
+
warnings.push("vite.config.ts : `base: './'` introuvable \u2014 les assets risquent de casser apr\xE8s d\xE9ploiement.");
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
function checkRouter(projectDir, warnings) {
|
|
405
|
+
const srcDir = path5.join(projectDir, "src");
|
|
406
|
+
if (!fs4.existsSync(srcDir)) return;
|
|
407
|
+
if (sourceUsesBrowserRouter(srcDir)) {
|
|
408
|
+
warnings.push("Usage de BrowserRouter d\xE9tect\xE9 \u2014 utilisez HashRouter pour l'h\xE9bergement versionn\xE9.");
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
function sourceUsesBrowserRouter(dir) {
|
|
412
|
+
for (const entry of fs4.readdirSync(dir, { withFileTypes: true })) {
|
|
413
|
+
const full = path5.join(dir, entry.name);
|
|
414
|
+
if (entry.isDirectory()) {
|
|
415
|
+
if (sourceUsesBrowserRouter(full)) return true;
|
|
416
|
+
} else if (/\.(tsx?|jsx?)$/.test(entry.name)) {
|
|
417
|
+
if (/\bBrowserRouter\b/.test(fs4.readFileSync(full, "utf8"))) return true;
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
return false;
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
// src/lib/zip.ts
|
|
424
|
+
import fs5 from "fs";
|
|
425
|
+
import path6 from "path";
|
|
426
|
+
import AdmZip2 from "adm-zip";
|
|
427
|
+
function zipDist(projectDir, name, version) {
|
|
428
|
+
const distDir = path6.join(projectDir, "dist");
|
|
429
|
+
if (!fs5.existsSync(distDir)) {
|
|
430
|
+
throw new Error("Dossier dist/ introuvable. Lancez le build avant de zipper.");
|
|
431
|
+
}
|
|
432
|
+
const zipFileName = `${name}-${version}.zip`;
|
|
433
|
+
const zipFilePath = path6.join(projectDir, zipFileName);
|
|
434
|
+
const zip = new AdmZip2();
|
|
435
|
+
zip.addLocalFolder(distDir);
|
|
436
|
+
zip.writeZip(zipFilePath);
|
|
437
|
+
return zipFilePath;
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
// src/commands/pack.ts
|
|
441
|
+
function pack(opts) {
|
|
442
|
+
const projectDir = path7.resolve(opts.dir ?? process.cwd());
|
|
443
|
+
let name;
|
|
444
|
+
let version;
|
|
445
|
+
try {
|
|
446
|
+
({ name, version } = getNameAndVersion(readManifest(projectDir)));
|
|
447
|
+
} catch (err) {
|
|
448
|
+
error(err.message);
|
|
449
|
+
return 1;
|
|
450
|
+
}
|
|
451
|
+
info("Build (npm run build)\u2026");
|
|
452
|
+
const buildCode = runNpm(["run", "build"], projectDir);
|
|
453
|
+
if (buildCode !== 0) {
|
|
454
|
+
error("Le build a \xE9chou\xE9 \u2014 corrigez les erreurs ci-dessus.");
|
|
455
|
+
return buildCode;
|
|
456
|
+
}
|
|
457
|
+
let zipPath;
|
|
458
|
+
try {
|
|
459
|
+
zipPath = zipDist(projectDir, name, version);
|
|
460
|
+
} catch (err) {
|
|
461
|
+
error(err.message);
|
|
462
|
+
return 1;
|
|
463
|
+
}
|
|
464
|
+
success(`Zip cr\xE9\xE9 : ${path7.basename(zipPath)}`);
|
|
465
|
+
info("V\xE9rification de conformit\xE9\u2026");
|
|
466
|
+
const result = verifyExtension({ projectDir, zipPath, requireDist: true });
|
|
467
|
+
const ok = printVerifyResult(result);
|
|
468
|
+
return ok ? 0 : 1;
|
|
469
|
+
}
|
|
470
|
+
|
|
471
|
+
// src/commands/verify.ts
|
|
472
|
+
import path8 from "path";
|
|
473
|
+
function verify(opts) {
|
|
474
|
+
const projectDir = path8.resolve(opts.dir ?? process.cwd());
|
|
475
|
+
info("V\xE9rification de conformit\xE9\u2026");
|
|
476
|
+
const result = verifyExtension({ projectDir, zipPath: opts.zip });
|
|
477
|
+
const ok = printVerifyResult(result);
|
|
478
|
+
return ok ? 0 : 1;
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
// src/cli.ts
|
|
482
|
+
var program = new Command();
|
|
483
|
+
program.name("inwink-extension").description("CLI pour bootstrapper, packager et v\xE9rifier les extensions backoffice inwink.").showHelpAfterError();
|
|
484
|
+
program.command("bootstrap").description("G\xE9n\xE8re le squelette d'une extension inwink.").requiredOption("--name <slug>", "slug unique de l'extension (minuscules, tirets)").requiredOption("--display-name <name>", "nom affich\xE9 dans l'interface inwink").requiredOption("--description <text>", "description courte de l'extension").requiredOption("--author <email>", "email de l'auteur").requiredOption("--event-api-url <url>", "URL de base de l'API Event inwink").requiredOption("--community-api-url <url>", "URL de base de l'API Community inwink").requiredOption("--customer-api-url <url>", "URL de base de l'API Customer inwink").option("--template <id>", "template \xE0 utiliser", "vite-extension").option("--dir <path>", "dossier cible (d\xE9faut : dossier courant)").option("--force", "\xE9crire m\xEAme si le dossier cible n'est pas vide", false).option("--install", "lancer npm install apr\xE8s la g\xE9n\xE9ration", false).action((options) => {
|
|
485
|
+
process.exitCode = bootstrap(options);
|
|
486
|
+
});
|
|
487
|
+
program.command("apply").description("Applique un template par-dessus un projet existant (overlay).").argument("<template>", "template \xE0 appliquer (sous-dossier de templates/)").option("--dir <path>", "dossier du projet (d\xE9faut : dossier courant)").action((template, options) => {
|
|
488
|
+
process.exitCode = apply(template, options);
|
|
489
|
+
});
|
|
490
|
+
program.command("package").description("Build + zip + v\xE9rification de conformit\xE9 de l'extension.").option("--dir <path>", "dossier du projet (d\xE9faut : dossier courant)").action((options) => {
|
|
491
|
+
process.exitCode = pack(options);
|
|
492
|
+
});
|
|
493
|
+
program.command("verify").description("V\xE9rifie la conformit\xE9 de l'extension (sans build).").option("--dir <path>", "dossier du projet (d\xE9faut : dossier courant)").option("--zip <file>", "chemin d'un zip \xE0 inspecter").action((options) => {
|
|
494
|
+
process.exitCode = verify(options);
|
|
495
|
+
});
|
|
496
|
+
program.parseAsync(process.argv).catch((err) => {
|
|
497
|
+
process.stderr.write(`${err.message}
|
|
498
|
+
`);
|
|
499
|
+
process.exitCode = 1;
|
|
500
|
+
});
|
package/package.json
ADDED
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@inwink-mcp/backoffice-extension-helpers",
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"author": "inwink",
|
|
5
|
+
"license": "ISC",
|
|
6
|
+
"description": "CLI pour bootstrapper, packager et vérifier les extensions backoffice inwink.",
|
|
7
|
+
"type": "module",
|
|
8
|
+
"bin": {
|
|
9
|
+
"inwink-extension": "./dist/cli.js"
|
|
10
|
+
},
|
|
11
|
+
"files": [
|
|
12
|
+
"dist",
|
|
13
|
+
"templates"
|
|
14
|
+
],
|
|
15
|
+
"engines": {
|
|
16
|
+
"node": ">=20"
|
|
17
|
+
},
|
|
18
|
+
"scripts": {
|
|
19
|
+
"build": "tsup",
|
|
20
|
+
"dev": "tsup --watch",
|
|
21
|
+
"prepublishOnly": "npm run build",
|
|
22
|
+
"test": "npm run build && node scripts/test.mjs",
|
|
23
|
+
"test-verify": "node dist/cli.js verify --dir .test-app"
|
|
24
|
+
},
|
|
25
|
+
"dependencies": {
|
|
26
|
+
"adm-zip": "^0.5.16",
|
|
27
|
+
"commander": "^13.0.0",
|
|
28
|
+
"picocolors": "^1.1.1"
|
|
29
|
+
},
|
|
30
|
+
"devDependencies": {
|
|
31
|
+
"@types/adm-zip": "^0.5.7",
|
|
32
|
+
"@types/node": "^22.14.1",
|
|
33
|
+
"tsup": "^8.3.5",
|
|
34
|
+
"typescript": "^5.8.3"
|
|
35
|
+
}
|
|
36
|
+
}
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
import { useState } from 'react';
|
|
2
|
+
import { FiLogIn, FiLogOut, FiRefreshCw } from 'react-icons/fi';
|
|
3
|
+
import { useAuth } from '../hooks/useAuth';
|
|
4
|
+
import { useInwinkEventApi } from '../hooks/useInwinkEventApi';
|
|
5
|
+
|
|
6
|
+
// Version de HomePage générée par le template overlay "test-app".
|
|
7
|
+
// Sert à vérifier que le squelette bootstrappé compile et que les hooks API
|
|
8
|
+
// (ici useInwinkEventApi) sont utilisables.
|
|
9
|
+
export default function HomePage() {
|
|
10
|
+
const { isAuthenticated, isLoading, login, logout, user } = useAuth();
|
|
11
|
+
const eventApi = useInwinkEventApi();
|
|
12
|
+
const [result, setResult] = useState<string>('');
|
|
13
|
+
const [error, setError] = useState<string>('');
|
|
14
|
+
|
|
15
|
+
async function callEventApi() {
|
|
16
|
+
setError('');
|
|
17
|
+
setResult('');
|
|
18
|
+
try {
|
|
19
|
+
const data = await eventApi.post('/dae274d9-e86e-f011-8dca-0022488a3cad/session/query', {
|
|
20
|
+
"selects": { "id": true, "title": true, "status": true, "moderators": { "person": { "id": true, "firstname": true, "lastname": true, "company": true, "mail": true } }, "startDate": true, "endDate": true, "room": { "name": true, "id": true }, "speakers": { "id": true, "firstname": true, "lastname": true, "company": true, "mail": true }, "exhibitors": { "id": true, "name": true }, "infos": { "registered": true, "attended": true, "attendedOnsite": true, "attendedOnlineLive": true, "attendedOnlineReplay": true } },
|
|
21
|
+
"orders": [{ "desc": true, "value": { "validFrom": {} } }],
|
|
22
|
+
"page": { "index": 0, "size": 200 }
|
|
23
|
+
});
|
|
24
|
+
setResult(JSON.stringify(data, null, 2));
|
|
25
|
+
} catch (err) {
|
|
26
|
+
setError(err instanceof Error ? err.message : String(err));
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
return (
|
|
31
|
+
<main>
|
|
32
|
+
<h1>__DISPLAY_NAME__ — test API Event</h1>
|
|
33
|
+
{!isAuthenticated ? (
|
|
34
|
+
<button disabled={isLoading} onClick={() => void login()}>
|
|
35
|
+
<FiLogIn aria-hidden />
|
|
36
|
+
{isLoading ? 'Initialisation...' : 'Se connecter'}
|
|
37
|
+
</button>
|
|
38
|
+
) : (
|
|
39
|
+
<>
|
|
40
|
+
<p>Connecté en tant que : {user?.profile.email ?? 'Inconnu'}</p>
|
|
41
|
+
<button onClick={() => void callEventApi()}>
|
|
42
|
+
<FiRefreshCw aria-hidden />
|
|
43
|
+
Appeler l'API Event
|
|
44
|
+
</button>
|
|
45
|
+
<button onClick={() => void logout()}>
|
|
46
|
+
<FiLogOut aria-hidden />
|
|
47
|
+
Se déconnecter
|
|
48
|
+
</button>
|
|
49
|
+
{result && <pre>{result}</pre>}
|
|
50
|
+
{error && <p role="alert">Erreur : {error}</p>}
|
|
51
|
+
</>
|
|
52
|
+
)}
|
|
53
|
+
</main>
|
|
54
|
+
);
|
|
55
|
+
}
|