@astratra/centrale-cli 0.0.0-stage → 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 CHANGED
@@ -1,3 +1,52 @@
1
- # Temporary Holding Version
1
+ # @astratra/centrale-cli
2
2
 
3
- This version is a temporary placeholder for this package. An operational version to replace this has been submitted for review and is awaiting a staged release.
3
+ L'outil en ligne de commande de **Centrale LJ** : il relie les erreurs minifiées à leur vraie ligne (cartes de code, par identifiant de débogage) et déclare les déploiements (version d'apparition, commit suspect). Aucune dépendance, aucun outil Sentry.
4
+
5
+ ## Commandes
6
+
7
+ ```sh
8
+ export CENTRALE_ADRESSE=https://centrale.exemple.fr
9
+ export CENTRALE_JETON=clj_dep_… # jeton de DÉPLOIEMENT du projet (secret de CI)
10
+
11
+ npx centrale injecter dist # après le build, avant la mise en ligne
12
+ npx centrale envoyer-cartes dist --version 2.14.0
13
+ npx centrale deploiement --version 2.14.0 --commit "$GITHUB_SHA" --depot kongo/scolaris --environnement production
14
+ ```
15
+
16
+ | Commande | Ce qu'elle fait |
17
+ | --- | --- |
18
+ | `injecter <dossier>` | Pour chaque `.js`/`.mjs`/`.cjs` qui a une carte (commentaire `sourceMappingURL` ou `<fichier>.map`) : calcule un identifiant stable (SHA-256 du contenu, mis en forme d'UUID), insère en tête une ligne qui l'enregistre dans `globalThis.__centraleDebugIds`, ajoute `//# debugId=<id>` en fin de fichier et `debug_id` dans la carte. Relancer ne change rien. |
19
+ | `envoyer-cartes <dossier> --version X` | Demande au backend les cartes qu'il a déjà, puis envoie les autres par lots compressés (16 Mio visés, 64 Mio au plus). Coupure, 5xx et 429 sont réessayés avec recul (`Retry-After` respecté). Relancer reprend où l'envoi s'est arrêté. |
20
+ | `deploiement --version X --commit SHA` | Déclare un déploiement. `--environnement` vaut par défaut celui du jeton. Déclarer deux fois le même ne crée pas de doublon. |
21
+
22
+ Codes de sortie : `0` réussi, `1` échec (réseau, jeton refusé, carte refusée), `2` commande mal formée.
23
+
24
+ ## Comment l'identifiant voyage
25
+
26
+ 1. `injecter` place en tête de chaque fichier (après `#!` et les directives `"use strict"`, `"use client"`, sur la même ligne pour ne décaler aucun numéro de ligne ; les colonnes de la carte sont recalées) :
27
+ `globalThis.__centraleDebugIds[new Error().stack] = "<id>"`.
28
+ 2. Au chargement, la pile capturée contient l'adresse réelle du fichier (domaine, hash du build compris).
29
+ 3. `@astratra/centrale` relit ces piles avec le même analyseur que les erreurs : chaque cadre reçoit le `debugId` de son fichier (et l'erreur aussi, s'il n'y en a qu'un).
30
+ 4. Le backend traduit les cadres avec la carte de même identifiant **du même projet**, à la lecture du détail d'un problème.
31
+
32
+ Sans identifiant (bytecode Hermes, build non injecté), le backend cherche une carte de **la même version** dont le chemin finit comme celui du cadre : nommez la carte comme le fichier des piles (par exemple `index.android.bundle.map` pour Expo/React Native).
33
+
34
+ ## Exemples de build
35
+
36
+ - **Vite / Astro** : `build.sourcemap: true` (ou `'hidden'` pour ne pas publier le commentaire), puis `centrale injecter dist`. Ne mettez pas les `.map` en ligne : supprimez-les après `envoyer-cartes`.
37
+ - **Node compilé** (tsc, esbuild) : `sourceMap: true`, puis `centrale injecter build`.
38
+ - **Expo / Hermes** : exportez avec les cartes (`npx expo export --dump-sourcemap`), puis `centrale injecter dist` (la carte reçoit son identifiant) et `centrale envoyer-cartes dist --version <version de l'app>`.
39
+
40
+ ## Bibliothèque
41
+
42
+ ```ts
43
+ import { injecterDossier, envoyerCartes, declarerDeploiement } from '@astratra/centrale-cli';
44
+ ```
45
+
46
+ Le paquet est écrit en TypeScript à syntaxe effaçable (Node 24 l'exécute directement dans le dépôt) ; il sera compilé en JavaScript au moment de sa publication, comme `@astratra/centrale`.
47
+
48
+ ## Tests
49
+
50
+ ```sh
51
+ npm test -w @astratra/centrale-cli
52
+ ```
package/dist/cli.d.ts ADDED
@@ -0,0 +1,27 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `centrale` : l'outil en ligne de commande de Centrale LJ.
4
+ * centrale injecter <dossier>
5
+ * centrale envoyer-cartes <dossier> --version X
6
+ * centrale deploiement --version X --commit SHA [--depot owner/repo] [--environnement production]
7
+ * Le jeton de déploiement se lit dans CENTRALE_JETON, l'adresse du backend dans CENTRALE_ADRESSE.
8
+ * Code de sortie : 0 si tout va bien, 1 en cas d'échec, 2 pour une commande mal formée.
9
+ */
10
+ import { type Connexion } from './envoyer.ts';
11
+ interface Sorties {
12
+ ecrire: (texte: string) => void;
13
+ erreur: (texte: string) => void;
14
+ }
15
+ interface Options {
16
+ env?: NodeJS.ProcessEnv;
17
+ sorties?: Sorties;
18
+ connexion?: Partial<Connexion>;
19
+ }
20
+ /** Sépare les arguments positionnels et les options `--nom valeur` / `--nom=valeur`. */
21
+ export declare function lireArguments(args: string[]): {
22
+ positionnels: string[];
23
+ options: Record<string, string>;
24
+ };
25
+ /** Exécute une commande ; rend le code de sortie. Ne lève pas. */
26
+ export declare function executer(args: string[], { env, sorties, connexion: surcharge }?: Options): Promise<number>;
27
+ export {};
package/dist/cli.js ADDED
@@ -0,0 +1,153 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * `centrale` : l'outil en ligne de commande de Centrale LJ.
4
+ * centrale injecter <dossier>
5
+ * centrale envoyer-cartes <dossier> --version X
6
+ * centrale deploiement --version X --commit SHA [--depot owner/repo] [--environnement production]
7
+ * Le jeton de déploiement se lit dans CENTRALE_JETON, l'adresse du backend dans CENTRALE_ADRESSE.
8
+ * Code de sortie : 0 si tout va bien, 1 en cas d'échec, 2 pour une commande mal formée.
9
+ */
10
+ import { existsSync, realpathSync, statSync } from 'node:fs';
11
+ import { resolve } from 'node:path';
12
+ import { pathToFileURL } from 'node:url';
13
+ import { cheminRelatif, injecterDossier } from "./injecter.js";
14
+ import { declarerDeploiement, envoyerCartes } from "./envoyer.js";
15
+ const AIDE = `Utilisation :
16
+ centrale injecter <dossier>
17
+ Ajoute un identifiant de débogage à chaque fichier JS du build et à sa carte (.map).
18
+ centrale envoyer-cartes <dossier> --version X
19
+ Envoie les cartes (.map) au backend ; celles qu'il a déjà sont sautées.
20
+ centrale deploiement --version X --commit SHA [--depot owner/repo] [--environnement production]
21
+ Déclare un déploiement (pour la version d'apparition et le commit suspect).
22
+
23
+ Variables d'environnement : CENTRALE_JETON (jeton de déploiement clj_dep_…), CENTRALE_ADRESSE (adresse du backend).`;
24
+ /** Sépare les arguments positionnels et les options `--nom valeur` / `--nom=valeur`. */
25
+ export function lireArguments(args) {
26
+ const positionnels = [];
27
+ const options = {};
28
+ for (let i = 0; i < args.length; i++) {
29
+ const a = args[i];
30
+ if (a.startsWith('--')) {
31
+ const egal = a.indexOf('=');
32
+ if (egal !== -1)
33
+ options[a.slice(2, egal)] = a.slice(egal + 1);
34
+ else if (i + 1 < args.length && !args[i + 1].startsWith('--'))
35
+ options[a.slice(2)] = args[++i];
36
+ else
37
+ options[a.slice(2)] = 'true';
38
+ }
39
+ else {
40
+ positionnels.push(a);
41
+ }
42
+ }
43
+ return { positionnels, options };
44
+ }
45
+ function connexionDe(env, sorties, surcharge = {}) {
46
+ const jeton = env.CENTRALE_JETON?.trim();
47
+ const adresse = env.CENTRALE_ADRESSE?.trim();
48
+ if (!jeton || !/^clj_dep_/.test(jeton)) {
49
+ sorties.erreur('CENTRALE_JETON manque ou n\'est pas un jeton de déploiement (clj_dep_…).');
50
+ return null;
51
+ }
52
+ if (!adresse || !/^https?:\/\//.test(adresse)) {
53
+ sorties.erreur('CENTRALE_ADRESSE manque (par exemple https://centrale.exemple.fr).');
54
+ return null;
55
+ }
56
+ return { adresse, jeton, ...surcharge };
57
+ }
58
+ function dossierValide(chemin, sorties) {
59
+ if (!chemin) {
60
+ sorties.erreur('Dossier manquant.');
61
+ return null;
62
+ }
63
+ const absolu = resolve(chemin);
64
+ if (!existsSync(absolu) || !statSync(absolu).isDirectory()) {
65
+ sorties.erreur(`Dossier introuvable : ${chemin}`);
66
+ return null;
67
+ }
68
+ return absolu;
69
+ }
70
+ /** Exécute une commande ; rend le code de sortie. Ne lève pas. */
71
+ export async function executer(args, { env = process.env, sorties = { ecrire: console.log, erreur: console.error }, connexion: surcharge } = {}) {
72
+ const [commande, ...reste] = args;
73
+ const { positionnels, options } = lireArguments(reste);
74
+ try {
75
+ switch (commande) {
76
+ case 'injecter': {
77
+ const dossier = dossierValide(positionnels[0], sorties);
78
+ if (!dossier)
79
+ return 2;
80
+ const resultats = injecterDossier(dossier);
81
+ const compte = { injecte: 0, deja: 0, 'carte-seule': 0, 'sans-carte': 0 };
82
+ for (const r of resultats) {
83
+ compte[r.etat]++;
84
+ if (r.etat !== 'sans-carte')
85
+ sorties.ecrire(`${r.etat === 'deja' ? '=' : '+'} ${cheminRelatif(dossier, r.fichier)} ${r.debugId}`);
86
+ }
87
+ sorties.ecrire(`${compte.injecte} injecté(s), ${compte.deja} déjà fait(s), ${compte['carte-seule']} carte(s) seule(s), ${compte['sans-carte']} fichier(s) JS sans carte.`);
88
+ return 0;
89
+ }
90
+ case 'envoyer-cartes': {
91
+ const dossier = dossierValide(positionnels[0], sorties);
92
+ if (!dossier)
93
+ return 2;
94
+ if (!options.version || options.version === 'true') {
95
+ sorties.erreur('--version est obligatoire.');
96
+ return 2;
97
+ }
98
+ const connexion = connexionDe(env, sorties, surcharge);
99
+ if (!connexion)
100
+ return 2;
101
+ const bilan = await envoyerCartes(connexion, { dossier, version: options.version, signaler: (m) => sorties.ecrire(`… ${m}`) });
102
+ for (const f of bilan.tropGrosses)
103
+ sorties.erreur(`Carte trop grosse, non envoyée : ${f}`);
104
+ for (const r of bilan.refusees)
105
+ sorties.erreur(`Carte refusée (${r.debugId}) : ${r.raison}`);
106
+ sorties.ecrire(`${bilan.envoyees.length} carte(s) envoyée(s), ${bilan.dejaConnues.length} déjà connue(s).`);
107
+ return bilan.refusees.length || bilan.tropGrosses.length ? 1 : 0;
108
+ }
109
+ case 'deploiement': {
110
+ if (!options.version || options.version === 'true' || !options.commit || options.commit === 'true') {
111
+ sorties.erreur('--version et --commit sont obligatoires.');
112
+ return 2;
113
+ }
114
+ const connexion = connexionDe(env, sorties, surcharge);
115
+ if (!connexion)
116
+ return 2;
117
+ await declarerDeploiement(connexion, {
118
+ version: options.version,
119
+ commit: options.commit,
120
+ ...(options.depot ? { depot: options.depot } : {}),
121
+ ...(options.environnement ? { environnement: options.environnement } : {})
122
+ });
123
+ sorties.ecrire(`Déploiement ${options.version} (${options.commit.slice(0, 12)}) déclaré.`);
124
+ return 0;
125
+ }
126
+ case undefined:
127
+ case 'aide':
128
+ case '--help':
129
+ case '-h':
130
+ sorties.ecrire(AIDE);
131
+ return commande === undefined ? 2 : 0;
132
+ default:
133
+ sorties.erreur(`Commande inconnue : ${commande}\n\n${AIDE}`);
134
+ return 2;
135
+ }
136
+ }
137
+ catch (erreur) {
138
+ sorties.erreur(`Échec : ${erreur instanceof Error ? erreur.message : String(erreur)}`);
139
+ return 1;
140
+ }
141
+ }
142
+ /* Lancé directement (bin `centrale`) : on exécute avec les arguments du processus. */
143
+ function lanceDirectement() {
144
+ try {
145
+ return Boolean(process.argv[1]) && import.meta.url === pathToFileURL(realpathSync(process.argv[1])).href;
146
+ }
147
+ catch {
148
+ return false;
149
+ }
150
+ }
151
+ if (lanceDirectement()) {
152
+ process.exitCode = await executer(process.argv.slice(2));
153
+ }
@@ -0,0 +1,64 @@
1
+ /**
2
+ * `centrale envoyer-cartes <dossier> --version X` et `centrale deploiement …` : le dialogue avec
3
+ * le backend, authentifié par le jeton de déploiement du projet (`clj_dep_…`).
4
+ *
5
+ * L'envoi des cartes se fait en trois temps :
6
+ * 1. on liste les cartes du dossier et leur identifiant (celui posé par `injecter`, sinon un
7
+ * identifiant tiré du contenu : le backend retrouvera alors la carte par version et nom de fichier) ;
8
+ * 2. on demande au backend celles qu'il a déjà (`POST /v1/cartes/connues`) : rien n'est renvoyé deux fois ;
9
+ * 3. on envoie le reste par lots compressés (`POST /v1/cartes`), chaque lot étant réessayé avec un
10
+ * recul progressif en cas de coupure, de 5xx ou de 429 (`Retry-After` respecté).
11
+ */
12
+ /** Taille visée d'un lot, avant compression : assez petite pour un réseau moyen, assez grande pour aller vite. */
13
+ export declare const LOT_OCTETS: number;
14
+ /** Le maximum accepté par le backend pour un lot une fois décompressé. */
15
+ export declare const LOT_MAX_OCTETS: number;
16
+ export interface Connexion {
17
+ adresse: string;
18
+ jeton: string;
19
+ /** Injectables pour les tests. */
20
+ fetch?: typeof fetch;
21
+ attendre?: (ms: number) => Promise<void>;
22
+ essais?: number;
23
+ reculBaseMs?: number;
24
+ }
25
+ export declare class ErreurCentrale extends Error {
26
+ statut: number;
27
+ constructor(message: string, statut: number);
28
+ }
29
+ /** Une requête JSON (gzip pour les gros corps), réessayée sur coupure, 408, 429 et 5xx. */
30
+ export declare function appeler(connexion: Connexion, chemin: string, corps: unknown): Promise<unknown>;
31
+ export interface CarteLocale {
32
+ chemin: string;
33
+ /** Chemin du fichier généré relatif au dossier (sans `.map`), par exemple `assets/index-BxK3.js`. */
34
+ fichier: string;
35
+ debugId: string;
36
+ taille: number;
37
+ /** Vrai si l'identifiant vient d'`injecter` (sinon il est tiré du contenu). */
38
+ injecte: boolean;
39
+ }
40
+ /** Les cartes d'un dossier de build et leur identifiant. Les cartes de CSS sont laissées de côté. */
41
+ export declare function cartesDe(dossier: string): CarteLocale[];
42
+ export interface BilanEnvoi {
43
+ envoyees: string[];
44
+ dejaConnues: string[];
45
+ tropGrosses: string[];
46
+ refusees: Array<{
47
+ debugId: string;
48
+ raison: string;
49
+ }>;
50
+ }
51
+ export declare function envoyerCartes(connexion: Connexion, { dossier, version, lotOctets, signaler }: {
52
+ dossier: string;
53
+ version?: string;
54
+ lotOctets?: number;
55
+ signaler?: (message: string) => void;
56
+ }): Promise<BilanEnvoi>;
57
+ export interface Deploiement {
58
+ version: string;
59
+ commit: string;
60
+ depot?: string;
61
+ environnement?: string;
62
+ deployeLe?: string;
63
+ }
64
+ export declare function declarerDeploiement(connexion: Connexion, deploiement: Deploiement): Promise<unknown>;
@@ -0,0 +1,148 @@
1
+ /**
2
+ * `centrale envoyer-cartes <dossier> --version X` et `centrale deploiement …` : le dialogue avec
3
+ * le backend, authentifié par le jeton de déploiement du projet (`clj_dep_…`).
4
+ *
5
+ * L'envoi des cartes se fait en trois temps :
6
+ * 1. on liste les cartes du dossier et leur identifiant (celui posé par `injecter`, sinon un
7
+ * identifiant tiré du contenu : le backend retrouvera alors la carte par version et nom de fichier) ;
8
+ * 2. on demande au backend celles qu'il a déjà (`POST /v1/cartes/connues`) : rien n'est renvoyé deux fois ;
9
+ * 3. on envoie le reste par lots compressés (`POST /v1/cartes`), chaque lot étant réessayé avec un
10
+ * recul progressif en cas de coupure, de 5xx ou de 429 (`Retry-After` respecté).
11
+ */
12
+ import { readFileSync, statSync } from 'node:fs';
13
+ import { gzipSync } from 'node:zlib';
14
+ import { cheminRelatif, debugIdDe, fichiersDe } from "./injecter.js";
15
+ /** Taille visée d'un lot, avant compression : assez petite pour un réseau moyen, assez grande pour aller vite. */
16
+ export const LOT_OCTETS = 16 * 1024 * 1024;
17
+ /** Le maximum accepté par le backend pour un lot une fois décompressé. */
18
+ export const LOT_MAX_OCTETS = 64 * 1024 * 1024;
19
+ const CONNUES_PAR_DEMANDE = 1000;
20
+ export class ErreurCentrale extends Error {
21
+ statut;
22
+ constructor(message, statut) { super(message); this.statut = statut; }
23
+ }
24
+ const attendreVraiment = (ms) => new Promise((r) => setTimeout(r, ms));
25
+ /** Une requête JSON (gzip pour les gros corps), réessayée sur coupure, 408, 429 et 5xx. */
26
+ export async function appeler(connexion, chemin, corps) {
27
+ const f = connexion.fetch ?? fetch;
28
+ const attendre = connexion.attendre ?? attendreVraiment;
29
+ const essais = Math.max(1, connexion.essais ?? 5);
30
+ const base = connexion.reculBaseMs ?? 1000;
31
+ const texte = JSON.stringify(corps);
32
+ const compresse = texte.length > 1024;
33
+ const contenu = compresse ? gzipSync(texte) : texte;
34
+ const adresse = connexion.adresse.replace(/\/+$/, '') + chemin;
35
+ let derniere;
36
+ for (let essai = 1; essai <= essais; essai++) {
37
+ let reponse;
38
+ try {
39
+ reponse = await f(adresse, {
40
+ method: 'POST',
41
+ headers: {
42
+ Authorization: `Bearer ${connexion.jeton}`,
43
+ 'Content-Type': 'application/json',
44
+ ...(compresse ? { 'Content-Encoding': 'gzip' } : {})
45
+ },
46
+ body: contenu
47
+ });
48
+ }
49
+ catch (erreur) {
50
+ derniere = erreur;
51
+ if (essai < essais)
52
+ await attendre(base * 2 ** (essai - 1));
53
+ continue;
54
+ }
55
+ const lu = await reponse.text();
56
+ let json = null;
57
+ try {
58
+ json = lu ? JSON.parse(lu) : null;
59
+ }
60
+ catch { /* réponse non JSON : on garde le statut */ }
61
+ if (reponse.ok)
62
+ return json;
63
+ const message = json?.message ?? `HTTP ${reponse.status}`;
64
+ derniere = new ErreurCentrale(message, reponse.status);
65
+ const temporaire = reponse.status === 408 || reponse.status === 429 || reponse.status >= 500;
66
+ if (!temporaire || essai === essais)
67
+ break;
68
+ const demande = Number(reponse.headers.get('retry-after'));
69
+ await attendre(Number.isFinite(demande) && demande > 0 ? Math.min(demande, 300) * 1000 : base * 2 ** (essai - 1));
70
+ }
71
+ throw derniere instanceof Error ? derniere : new ErreurCentrale(String(derniere), 0);
72
+ }
73
+ /** Les cartes d'un dossier de build et leur identifiant. Les cartes de CSS sont laissées de côté. */
74
+ export function cartesDe(dossier) {
75
+ const sortie = [];
76
+ for (const chemin of fichiersDe(dossier)) {
77
+ if (!chemin.endsWith('.map') || chemin.endsWith('.css.map'))
78
+ continue;
79
+ const texte = readFileSync(chemin, 'utf8');
80
+ let carte;
81
+ try {
82
+ carte = JSON.parse(texte);
83
+ }
84
+ catch {
85
+ continue;
86
+ }
87
+ if (!carte || typeof carte !== 'object' || typeof carte.mappings !== 'string')
88
+ continue;
89
+ const pose = typeof carte.debug_id === 'string' ? carte.debug_id : typeof carte.debugId === 'string' ? carte.debugId : null;
90
+ sortie.push({
91
+ chemin,
92
+ fichier: cheminRelatif(dossier, chemin).replace(/\.map$/, ''),
93
+ debugId: (pose ?? debugIdDe(texte)).toLowerCase(),
94
+ taille: statSync(chemin).size,
95
+ injecte: pose !== null
96
+ });
97
+ }
98
+ return sortie;
99
+ }
100
+ export async function envoyerCartes(connexion, { dossier, version, lotOctets = LOT_OCTETS, signaler = () => { } }) {
101
+ const cartes = cartesDe(dossier);
102
+ const bilan = { envoyees: [], dejaConnues: [], tropGrosses: [], refusees: [] };
103
+ const connues = new Set();
104
+ for (let i = 0; i < cartes.length; i += CONNUES_PAR_DEMANDE) {
105
+ const morceau = cartes.slice(i, i + CONNUES_PAR_DEMANDE).map((c) => c.debugId);
106
+ const reponse = await appeler(connexion, '/v1/cartes/connues', { debugIds: morceau });
107
+ for (const id of reponse?.connues ?? [])
108
+ connues.add(id.toLowerCase());
109
+ }
110
+ const aEnvoyer = [];
111
+ for (const c of cartes) {
112
+ if (connues.has(c.debugId))
113
+ bilan.dejaConnues.push(c.debugId);
114
+ else if (c.taille > LOT_MAX_OCTETS - 1024)
115
+ bilan.tropGrosses.push(c.fichier);
116
+ else
117
+ aEnvoyer.push(c);
118
+ }
119
+ /* Des lots d'au plus `lotOctets` (une carte plus grosse part seule). */
120
+ let lot = [];
121
+ let taille = 0;
122
+ const partir = async () => {
123
+ if (!lot.length)
124
+ return;
125
+ const corps = {
126
+ ...(version ? { version } : {}),
127
+ cartes: lot.map((c) => ({ debugId: c.debugId, fichier: c.fichier, carte: readFileSync(c.chemin, 'utf8') }))
128
+ };
129
+ const reponse = await appeler(connexion, '/v1/cartes', corps);
130
+ bilan.envoyees.push(...(reponse?.enregistrees ?? []));
131
+ bilan.dejaConnues.push(...(reponse?.dejaConnues ?? []));
132
+ bilan.refusees.push(...(reponse?.refusees ?? []));
133
+ signaler(`${bilan.envoyees.length + bilan.dejaConnues.length}/${cartes.length}`);
134
+ lot = [];
135
+ taille = 0;
136
+ };
137
+ for (const c of aEnvoyer) {
138
+ if (lot.length && taille + c.taille > lotOctets)
139
+ await partir();
140
+ lot.push(c);
141
+ taille += c.taille;
142
+ }
143
+ await partir();
144
+ return bilan;
145
+ }
146
+ export async function declarerDeploiement(connexion, deploiement) {
147
+ return appeler(connexion, '/v1/deploiements', deploiement);
148
+ }
@@ -0,0 +1,6 @@
1
+ /** `@astratra/centrale-cli` utilisé comme bibliothèque (scripts de build, tests). */
2
+ export { injecterDossier, injecterFichier, marquerCarteSeule, debugIdDe, ligneEnregistrement, pointDInsertion, GLOBALE } from './injecter.ts';
3
+ export type { Resultat } from './injecter.ts';
4
+ export { envoyerCartes, declarerDeploiement, cartesDe, appeler, ErreurCentrale, LOT_OCTETS, LOT_MAX_OCTETS } from './envoyer.ts';
5
+ export type { Connexion, CarteLocale, BilanEnvoi, Deploiement } from './envoyer.ts';
6
+ export { executer } from './cli.ts';
package/dist/index.js ADDED
@@ -0,0 +1,4 @@
1
+ /** `@astratra/centrale-cli` utilisé comme bibliothèque (scripts de build, tests). */
2
+ export { injecterDossier, injecterFichier, marquerCarteSeule, debugIdDe, ligneEnregistrement, pointDInsertion, GLOBALE } from "./injecter.js";
3
+ export { envoyerCartes, declarerDeploiement, cartesDe, appeler, ErreurCentrale, LOT_OCTETS, LOT_MAX_OCTETS } from "./envoyer.js";
4
+ export { executer } from "./cli.js";
@@ -0,0 +1,60 @@
1
+ /**
2
+ * `centrale injecter <dossier>` : donne à chaque fichier JavaScript d'un build un identifiant
3
+ * de débogage (debug ID) stable, et l'écrit à trois endroits :
4
+ * 1. dans le JavaScript, une ligne d'enregistrement exécutée au chargement du fichier :
5
+ * `globalThis.__centraleDebugIds[new Error().stack] = "<id>"`. La pile capturée commence
6
+ * par l'adresse du fichier tel que le navigateur, Node ou Hermes le voit vraiment ; le paquet
7
+ * de collecte relit ces piles et sait ainsi quel fichier (adresse finale, hash compris)
8
+ * porte quel identifiant, sans rien supposer du serveur web ni du nom des fichiers ;
9
+ * 2. à la fin du JavaScript, le commentaire `//# debugId=<id>` (lisible par un humain et par les outils) ;
10
+ * 3. dans la carte (.map), les champs `debug_id` et `debugId`.
11
+ *
12
+ * L'identifiant est dérivé du contenu (SHA-256 tronqué mis en forme d'UUID) : le même build
13
+ * donne le même identifiant, et relancer la commande ne change rien (idempotence).
14
+ *
15
+ * La ligne d'enregistrement est insérée au DÉBUT du fichier (après un éventuel `#!` et les
16
+ * directives comme "use strict" ou "use client", qui doivent rester en tête), SUR LA MÊME
17
+ * LIGNE : les numéros de ligne ne bougent pas, seules les colonnes de cette ligne sont décalées
18
+ * dans la carte. En tête, elle s'exécute avant tout le code du fichier : même une erreur levée
19
+ * au chargement est reliée à sa carte.
20
+ *
21
+ * Les fichiers qui ne sont pas du JavaScript texte (bytecode Hermes `.hbc`) ne peuvent pas
22
+ * recevoir cette ligne : leur carte reçoit quand même un identifiant, et le backend les retrouve
23
+ * par la version et le nom du fichier.
24
+ */
25
+ export declare const GLOBALE = "__centraleDebugIds";
26
+ export type Resultat = {
27
+ fichier: string;
28
+ carte: string;
29
+ debugId: string;
30
+ etat: 'injecte' | 'deja';
31
+ } | {
32
+ fichier: string;
33
+ carte: string;
34
+ debugId: string;
35
+ etat: 'carte-seule';
36
+ } | {
37
+ fichier: string;
38
+ etat: 'sans-carte';
39
+ };
40
+ /** Un identifiant au format UUID (version 4, variante RFC 4122) tiré d'un SHA-256 : stable pour un même contenu. */
41
+ export declare function debugIdDe(contenu: string | Buffer): string;
42
+ /** La ligne d'enregistrement. Ne lève jamais, même dans un moteur sans `globalThis`. */
43
+ export declare function ligneEnregistrement(debugId: string): string;
44
+ /** Tous les fichiers d'un dossier, récursivement, sans node_modules ni .git. */
45
+ export declare function fichiersDe(dossier: string): string[];
46
+ /** La carte d'un fichier JS : celle de son commentaire `sourceMappingURL` (si locale), sinon `<fichier>.map`. */
47
+ export declare function carteDe(fichierJs: string, contenu: string): string | null;
48
+ /**
49
+ * Où insérer la ligne d'enregistrement : après un `#!` et après les directives de tête
50
+ * ("use strict", "use client"…), commentaires compris. Rend l'indice dans le texte.
51
+ */
52
+ export declare function pointDInsertion(code: string): number;
53
+ /** Traite un fichier JavaScript et sa carte. Sans danger à relancer. */
54
+ export declare function injecterFichier(fichierJs: string): Resultat;
55
+ /** Une carte sans JavaScript texte à côté (bytecode Hermes…) : seulement l'identifiant, tiré de son contenu. */
56
+ export declare function marquerCarteSeule(cheminCarte: string): Resultat;
57
+ /** Tout un dossier de build : les JS avec carte, puis les cartes restées seules. */
58
+ export declare function injecterDossier(dossier: string): Resultat[];
59
+ /** Chemin relatif lisible, avec des « / » partout. */
60
+ export declare function cheminRelatif(dossier: string, fichier: string): string;
@@ -0,0 +1,208 @@
1
+ /**
2
+ * `centrale injecter <dossier>` : donne à chaque fichier JavaScript d'un build un identifiant
3
+ * de débogage (debug ID) stable, et l'écrit à trois endroits :
4
+ * 1. dans le JavaScript, une ligne d'enregistrement exécutée au chargement du fichier :
5
+ * `globalThis.__centraleDebugIds[new Error().stack] = "<id>"`. La pile capturée commence
6
+ * par l'adresse du fichier tel que le navigateur, Node ou Hermes le voit vraiment ; le paquet
7
+ * de collecte relit ces piles et sait ainsi quel fichier (adresse finale, hash compris)
8
+ * porte quel identifiant, sans rien supposer du serveur web ni du nom des fichiers ;
9
+ * 2. à la fin du JavaScript, le commentaire `//# debugId=<id>` (lisible par un humain et par les outils) ;
10
+ * 3. dans la carte (.map), les champs `debug_id` et `debugId`.
11
+ *
12
+ * L'identifiant est dérivé du contenu (SHA-256 tronqué mis en forme d'UUID) : le même build
13
+ * donne le même identifiant, et relancer la commande ne change rien (idempotence).
14
+ *
15
+ * La ligne d'enregistrement est insérée au DÉBUT du fichier (après un éventuel `#!` et les
16
+ * directives comme "use strict" ou "use client", qui doivent rester en tête), SUR LA MÊME
17
+ * LIGNE : les numéros de ligne ne bougent pas, seules les colonnes de cette ligne sont décalées
18
+ * dans la carte. En tête, elle s'exécute avant tout le code du fichier : même une erreur levée
19
+ * au chargement est reliée à sa carte.
20
+ *
21
+ * Les fichiers qui ne sont pas du JavaScript texte (bytecode Hermes `.hbc`) ne peuvent pas
22
+ * recevoir cette ligne : leur carte reçoit quand même un identifiant, et le backend les retrouve
23
+ * par la version et le nom du fichier.
24
+ */
25
+ import { createHash } from 'node:crypto';
26
+ import { existsSync, readdirSync, readFileSync, statSync, writeFileSync } from 'node:fs';
27
+ import { basename, dirname, join, relative, resolve, sep } from 'node:path';
28
+ import { decalerColonnes } from "./vlq.js";
29
+ export const GLOBALE = '__centraleDebugIds';
30
+ const COMMENTAIRE = /\/\/# debugId=([0-9a-fA-F-]{36})\s*$/m;
31
+ const EXTENSIONS_JS = /\.(?:m|c)?js$/;
32
+ const DOSSIERS_IGNORES = new Set(['node_modules', '.git']);
33
+ /** Un identifiant au format UUID (version 4, variante RFC 4122) tiré d'un SHA-256 : stable pour un même contenu. */
34
+ export function debugIdDe(contenu) {
35
+ const octets = createHash('sha256').update(contenu).digest().subarray(0, 16);
36
+ octets[6] = (octets[6] & 0x0f) | 0x40;
37
+ octets[8] = (octets[8] & 0x3f) | 0x80;
38
+ const h = octets.toString('hex');
39
+ return `${h.slice(0, 8)}-${h.slice(8, 12)}-${h.slice(12, 16)}-${h.slice(16, 20)}-${h.slice(20)}`;
40
+ }
41
+ /** La ligne d'enregistrement. Ne lève jamais, même dans un moteur sans `globalThis`. */
42
+ export function ligneEnregistrement(debugId) {
43
+ return `;!function(){try{var g=typeof globalThis!="undefined"?globalThis:typeof self!="undefined"?self:typeof window!="undefined"?window:typeof global!="undefined"?global:{},s=(new g.Error).stack;if(s){g.${GLOBALE}=g.${GLOBALE}||{};g.${GLOBALE}[s]="${debugId}"}}catch(e){}}();`;
44
+ }
45
+ /** Tous les fichiers d'un dossier, récursivement, sans node_modules ni .git. */
46
+ export function fichiersDe(dossier) {
47
+ const sortie = [];
48
+ const parcourir = (d) => {
49
+ for (const entree of readdirSync(d, { withFileTypes: true })) {
50
+ if (entree.isDirectory()) {
51
+ if (!DOSSIERS_IGNORES.has(entree.name))
52
+ parcourir(join(d, entree.name));
53
+ }
54
+ else if (entree.isFile()) {
55
+ sortie.push(join(d, entree.name));
56
+ }
57
+ }
58
+ };
59
+ parcourir(dossier);
60
+ return sortie.sort();
61
+ }
62
+ /** La carte d'un fichier JS : celle de son commentaire `sourceMappingURL` (si locale), sinon `<fichier>.map`. */
63
+ export function carteDe(fichierJs, contenu) {
64
+ const commentaires = [...contenu.matchAll(/\/\/[#@] sourceMappingURL=(\S+)\s*$/gm)];
65
+ const adresse = commentaires.at(-1)?.[1];
66
+ if (adresse && !/^[a-z]+:/i.test(adresse)) {
67
+ let chemin;
68
+ try {
69
+ chemin = resolve(dirname(fichierJs), decodeURIComponent(adresse.split(/[?#]/)[0]));
70
+ }
71
+ catch {
72
+ chemin = '';
73
+ }
74
+ if (chemin && existsSync(chemin) && statSync(chemin).isFile())
75
+ return chemin;
76
+ }
77
+ const voisine = `${fichierJs}.map`;
78
+ return existsSync(voisine) ? voisine : null;
79
+ }
80
+ /**
81
+ * Où insérer la ligne d'enregistrement : après un `#!` et après les directives de tête
82
+ * ("use strict", "use client"…), commentaires compris. Rend l'indice dans le texte.
83
+ */
84
+ export function pointDInsertion(code) {
85
+ let i = 0;
86
+ if (code.startsWith('#!')) {
87
+ const fin = code.indexOf('\n');
88
+ i = fin === -1 ? code.length : fin + 1;
89
+ }
90
+ const depart = i;
91
+ let apresDirectives = -1;
92
+ const blanc = /\s/;
93
+ for (;;) {
94
+ while (i < code.length && blanc.test(code[i]))
95
+ i++;
96
+ if (code.startsWith('//', i)) {
97
+ const fin = code.indexOf('\n', i);
98
+ i = fin === -1 ? code.length : fin + 1;
99
+ continue;
100
+ }
101
+ if (code.startsWith('/*', i)) {
102
+ const fin = code.indexOf('*/', i + 2);
103
+ if (fin === -1)
104
+ break;
105
+ i = fin + 2;
106
+ continue;
107
+ }
108
+ const directive = /^(['"])(?:[^'"\\\n]|\\.)*\1/.exec(code.slice(i, i + 500));
109
+ if (!directive)
110
+ break;
111
+ let j = i + directive[0].length;
112
+ while (code[j] === ' ' || code[j] === '\t')
113
+ j++;
114
+ // Une chaîne suivie d'autre chose qu'un « ; » ou une fin de ligne (`"a" + b`) n'est pas une directive.
115
+ if (code[j] === ';')
116
+ j++;
117
+ else if (j < code.length && code[j] !== '\n' && code[j] !== '\r')
118
+ break;
119
+ i = j;
120
+ apresDirectives = i;
121
+ }
122
+ return apresDirectives === -1 ? depart : apresDirectives;
123
+ }
124
+ /** Ligne et colonne (à partir de 0) d'un indice dans un texte. */
125
+ function positionDe(texte, indice) {
126
+ let ligne = 0;
127
+ let debutLigne = 0;
128
+ for (let i = 0; i < indice; i++) {
129
+ if (texte.charCodeAt(i) === 10) {
130
+ ligne++;
131
+ debutLigne = i + 1;
132
+ }
133
+ }
134
+ return { ligne, colonne: indice - debutLigne };
135
+ }
136
+ function lireCarte(chemin) {
137
+ const valeur = JSON.parse(readFileSync(chemin, 'utf8'));
138
+ if (!valeur || typeof valeur !== 'object' || Array.isArray(valeur))
139
+ throw new Error(`carte illisible : ${chemin}`);
140
+ return valeur;
141
+ }
142
+ /** Écrit l'identifiant dans la carte (`debug_id` et `debugId`) si besoin ; `decaler` corrige les colonnes. */
143
+ function marquerCarte(chemin, debugId, decaler) {
144
+ const carte = lireCarte(chemin);
145
+ if (carte.debug_id === debugId && carte.debugId === debugId && !decaler)
146
+ return;
147
+ if (decaler && typeof carte.mappings === 'string') {
148
+ carte.mappings = decalerColonnes(carte.mappings, decaler.ligne, decaler.colonne, decaler.longueur);
149
+ }
150
+ carte.debug_id = debugId;
151
+ carte.debugId = debugId;
152
+ writeFileSync(chemin, JSON.stringify(carte));
153
+ }
154
+ /** Traite un fichier JavaScript et sa carte. Sans danger à relancer. */
155
+ export function injecterFichier(fichierJs) {
156
+ const code = readFileSync(fichierJs, 'utf8');
157
+ const carte = carteDe(fichierJs, code);
158
+ if (!carte)
159
+ return { fichier: fichierJs, etat: 'sans-carte' };
160
+ const deja = COMMENTAIRE.exec(code);
161
+ if (deja && code.includes(GLOBALE)) {
162
+ const debugId = deja[1].toLowerCase();
163
+ marquerCarte(carte, debugId);
164
+ return { fichier: fichierJs, carte, debugId, etat: 'deja' };
165
+ }
166
+ const debugId = debugIdDe(code);
167
+ const ligne = ligneEnregistrement(debugId);
168
+ const indice = pointDInsertion(code);
169
+ const position = positionDe(code, indice);
170
+ const avecFinDeLigne = code.endsWith('\n') ? code : code + '\n';
171
+ const nouveau = avecFinDeLigne.slice(0, indice) + ligne + avecFinDeLigne.slice(indice) + `//# debugId=${debugId}\n`;
172
+ // La carte d'abord : si elle est illisible, le JavaScript reste intact.
173
+ marquerCarte(carte, debugId, { ...position, longueur: ligne.length });
174
+ writeFileSync(fichierJs, nouveau);
175
+ return { fichier: fichierJs, carte, debugId, etat: 'injecte' };
176
+ }
177
+ /** Une carte sans JavaScript texte à côté (bytecode Hermes…) : seulement l'identifiant, tiré de son contenu. */
178
+ export function marquerCarteSeule(cheminCarte) {
179
+ const carte = lireCarte(cheminCarte);
180
+ const existant = typeof carte.debug_id === 'string' ? carte.debug_id : typeof carte.debugId === 'string' ? carte.debugId : null;
181
+ const { debug_id: _a, debugId: _b, ...sansId } = carte;
182
+ const debugId = existant ?? debugIdDe(JSON.stringify(sansId));
183
+ marquerCarte(cheminCarte, debugId);
184
+ return { fichier: cheminCarte.replace(/\.map$/, ''), carte: cheminCarte, debugId, etat: 'carte-seule' };
185
+ }
186
+ /** Tout un dossier de build : les JS avec carte, puis les cartes restées seules. */
187
+ export function injecterDossier(dossier) {
188
+ const tous = fichiersDe(dossier);
189
+ const resultats = [];
190
+ const cartesTraitees = new Set();
191
+ for (const f of tous.filter((f) => EXTENSIONS_JS.test(f))) {
192
+ const r = injecterFichier(f);
193
+ resultats.push(r);
194
+ if ('carte' in r)
195
+ cartesTraitees.add(resolve(r.carte));
196
+ }
197
+ for (const c of tous.filter((f) => f.endsWith('.map') && !cartesTraitees.has(resolve(f)))) {
198
+ // Une carte de CSS n'a rien à faire ici.
199
+ if (/\.css\.map$/.test(c))
200
+ continue;
201
+ resultats.push(marquerCarteSeule(c));
202
+ }
203
+ return resultats;
204
+ }
205
+ /** Chemin relatif lisible, avec des « / » partout. */
206
+ export function cheminRelatif(dossier, fichier) {
207
+ return relative(dossier, fichier).split(sep).join('/') || basename(fichier);
208
+ }
package/dist/vlq.d.ts ADDED
@@ -0,0 +1,14 @@
1
+ /**
2
+ * Le codage VLQ en base 64 des cartes de code (source maps, version 3), réduit à ce dont
3
+ * l'injection a besoin : décaler les colonnes d'une ligne générée quand on y insère du code.
4
+ * Sans dépendance. Le backend a son propre décodeur complet (backend/src/modules/carteCode.ts).
5
+ */
6
+ /** Lit une valeur VLQ à partir de `debut` ; rend la valeur et la position qui suit. */
7
+ export declare function lireVlq(texte: string, debut: number): [number, number];
8
+ export declare function ecrireVlq(valeur: number): string;
9
+ /**
10
+ * Décale de `longueur` les segments de la ligne générée `ligne` (0 = première) situés à la
11
+ * colonne `colonne` ou après. Dans une ligne, seule la première colonne est absolue, les
12
+ * suivantes sont relatives : il suffit donc de corriger le premier segment concerné.
13
+ */
14
+ export declare function decalerColonnes(mappings: string, ligne: number, colonne: number, longueur: number): string;
package/dist/vlq.js ADDED
@@ -0,0 +1,65 @@
1
+ /**
2
+ * Le codage VLQ en base 64 des cartes de code (source maps, version 3), réduit à ce dont
3
+ * l'injection a besoin : décaler les colonnes d'une ligne générée quand on y insère du code.
4
+ * Sans dépendance. Le backend a son propre décodeur complet (backend/src/modules/carteCode.ts).
5
+ */
6
+ const ALPHABET = 'ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/';
7
+ const VALEURS = new Map([...ALPHABET].map((c, i) => [c, i]));
8
+ /** Lit une valeur VLQ à partir de `debut` ; rend la valeur et la position qui suit. */
9
+ export function lireVlq(texte, debut) {
10
+ let resultat = 0;
11
+ let decalage = 0;
12
+ let i = debut;
13
+ for (;;) {
14
+ if (i >= texte.length)
15
+ throw new Error('VLQ tronqué');
16
+ const chiffre = VALEURS.get(texte[i++]);
17
+ if (chiffre === undefined)
18
+ throw new Error('VLQ invalide');
19
+ resultat += (chiffre & 31) * 2 ** decalage;
20
+ decalage += 5;
21
+ if (!(chiffre & 32))
22
+ break;
23
+ if (decalage > 35)
24
+ throw new Error('VLQ trop long');
25
+ }
26
+ const negatif = resultat % 2 === 1;
27
+ const valeur = Math.floor(resultat / 2);
28
+ return [negatif ? -valeur : valeur, i];
29
+ }
30
+ export function ecrireVlq(valeur) {
31
+ let reste = valeur < 0 ? (-valeur) * 2 + 1 : valeur * 2;
32
+ let sortie = '';
33
+ do {
34
+ let chiffre = reste & 31;
35
+ reste = Math.floor(reste / 32);
36
+ if (reste > 0)
37
+ chiffre |= 32;
38
+ sortie += ALPHABET[chiffre];
39
+ } while (reste > 0);
40
+ return sortie;
41
+ }
42
+ /**
43
+ * Décale de `longueur` les segments de la ligne générée `ligne` (0 = première) situés à la
44
+ * colonne `colonne` ou après. Dans une ligne, seule la première colonne est absolue, les
45
+ * suivantes sont relatives : il suffit donc de corriger le premier segment concerné.
46
+ */
47
+ export function decalerColonnes(mappings, ligne, colonne, longueur) {
48
+ const lignes = mappings.split(';');
49
+ if (ligne >= lignes.length || !lignes[ligne])
50
+ return mappings;
51
+ const segments = lignes[ligne].split(',');
52
+ let absolue = 0;
53
+ for (let i = 0; i < segments.length; i++) {
54
+ if (!segments[i])
55
+ continue;
56
+ const [delta, fin] = lireVlq(segments[i], 0);
57
+ absolue += delta;
58
+ if (absolue >= colonne) {
59
+ segments[i] = ecrireVlq(delta + longueur) + segments[i].slice(fin);
60
+ break;
61
+ }
62
+ }
63
+ lignes[ligne] = segments.join(',');
64
+ return lignes.join(';');
65
+ }
package/package.json CHANGED
@@ -1,6 +1,41 @@
1
1
  {
2
2
  "name": "@astratra/centrale-cli",
3
- "version": "0.0.0-stage",
4
- "stub": true,
5
- "description": "Temporary package placeholder for staged publishing"
6
- }
3
+ "version": "1.0.0",
4
+ "description": "Outil en ligne de commande de Centrale LJ : identifiants de débogage dans les builds, envoi des cartes de code, déclaration des déploiements.",
5
+ "type": "module",
6
+ "bin": {
7
+ "centrale": "./dist/cli.js"
8
+ },
9
+ "exports": {
10
+ ".": {
11
+ "types": "./dist/index.d.ts",
12
+ "default": "./dist/index.js"
13
+ }
14
+ },
15
+ "files": [
16
+ "dist",
17
+ "README.md"
18
+ ],
19
+ "engines": {
20
+ "node": ">=24"
21
+ },
22
+ "scripts": {
23
+ "build": "rm -rf dist && tsc -p tsconfig.build.json && chmod +x dist/cli.js",
24
+ "prepack": "npm run build",
25
+ "test": "node --test \"test/**/*.test.ts\"",
26
+ "typecheck": "tsc -p tsconfig.json --noEmit"
27
+ },
28
+ "main": "./dist/index.js",
29
+ "types": "./dist/index.d.ts",
30
+ "keywords": [
31
+ "astratra",
32
+ "centrale",
33
+ "source-maps",
34
+ "debug-id",
35
+ "cli"
36
+ ],
37
+ "license": "MIT",
38
+ "publishConfig": {
39
+ "access": "public"
40
+ }
41
+ }