eoas 3.0.5 → 3.1.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 +5 -5
- package/bin/dev.js +3 -0
- package/bin/run.js +3 -0
- package/dist/commands/generate-certs.js +22 -2
- package/dist/commands/init.js +2 -2
- package/dist/commands/publish.js +55 -31
- package/dist/commands/republish.js +87 -27
- package/dist/commands/server/init.d.ts +17 -0
- package/dist/commands/server/init.js +654 -0
- package/dist/commands/server/validate.d.ts +10 -0
- package/dist/commands/server/validate.js +115 -0
- package/dist/lib/assets.d.ts +30 -8
- package/dist/lib/assets.js +158 -4
- package/dist/lib/auth.js +2 -1
- package/dist/lib/log.d.ts +8 -2
- package/dist/lib/log.js +47 -31
- package/dist/lib/ora.d.ts +10 -9
- package/dist/lib/ora.js +49 -88
- package/dist/lib/packageRunner.js +2 -1
- package/dist/lib/prompts.d.ts +32 -0
- package/dist/lib/prompts.js +86 -1
- package/dist/lib/serverConfig/choices.d.ts +62 -0
- package/dist/lib/serverConfig/choices.js +65 -0
- package/dist/lib/serverConfig/envCatalog.d.ts +41 -0
- package/dist/lib/serverConfig/envCatalog.js +582 -0
- package/dist/lib/serverConfig/helmValues.d.ts +22 -0
- package/dist/lib/serverConfig/helmValues.js +281 -0
- package/dist/lib/serverConfig/passwordPolicy.d.ts +3 -0
- package/dist/lib/serverConfig/passwordPolicy.js +36 -0
- package/dist/lib/serverUpdates.d.ts +45 -0
- package/dist/lib/serverUpdates.js +108 -0
- package/dist/lib/utils.d.ts +1 -0
- package/dist/lib/utils.js +18 -14
- package/package.json +8 -6
|
@@ -0,0 +1,281 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Renders and validates the Helm deployment pair for the chart (helm/ in the
|
|
3
|
+
// server repository): values.yaml carries the toggles and the structure of the
|
|
4
|
+
// environment list (committable, no secret), secrets.yaml is a values overlay
|
|
5
|
+
// holding every value in the secretEnv map. The chart renders that map into
|
|
6
|
+
// the secretName Secret, so one `helm install -f values.yaml -f secrets.yaml`
|
|
7
|
+
// deploys everything.
|
|
8
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
9
|
+
exports.validateHelmPair = exports.looksLikeChartValues = exports.parseYamlFile = exports.extractSecretEnv = exports.renderHelmSecretsValues = exports.renderHelmValues = exports.HELM_SECRETS_FILE = exports.HELM_SECRET_NAME = void 0;
|
|
10
|
+
const js_yaml_1 = require("js-yaml");
|
|
11
|
+
const choices_1 = require("./choices");
|
|
12
|
+
const envCatalog_1 = require("./envCatalog");
|
|
13
|
+
const passwordPolicy_1 = require("./passwordPolicy");
|
|
14
|
+
exports.HELM_SECRET_NAME = 'xprem-secrets';
|
|
15
|
+
exports.HELM_SECRETS_FILE = 'secrets.yaml';
|
|
16
|
+
// The chart injects PROMETHEUS_ENABLED from podAnnotations, never from the
|
|
17
|
+
// environment list; computed vars (helmKey) come from the values toggles.
|
|
18
|
+
const CHART_OWNED_VARS = new Set(['PROMETHEUS_ENABLED']);
|
|
19
|
+
function yamlString(value) {
|
|
20
|
+
return JSON.stringify(value);
|
|
21
|
+
}
|
|
22
|
+
function ingressHost(choices) {
|
|
23
|
+
if (choices.baseUrl) {
|
|
24
|
+
try {
|
|
25
|
+
return new URL(choices.baseUrl).hostname;
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
// Fall through to the placeholder.
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
return '<your-ota-domain>';
|
|
32
|
+
}
|
|
33
|
+
/** Renders the values.yaml content: toggles and structure, no secret values. */
|
|
34
|
+
function renderHelmValues(choices) {
|
|
35
|
+
const host = ingressHost(choices);
|
|
36
|
+
const lines = [
|
|
37
|
+
'# Generated by eoas server:init. Safe to commit: every value lives in',
|
|
38
|
+
`# ${exports.HELM_SECRETS_FILE} (gitignored), which the chart renders into the`,
|
|
39
|
+
`# "${exports.HELM_SECRET_NAME}" Secret. Deploy both files together:`,
|
|
40
|
+
`# helm install xprem <chart> -f values.yaml -f ${exports.HELM_SECRETS_FILE} -n <namespace>`,
|
|
41
|
+
'# Check the pair with: eoas server:validate <this directory>',
|
|
42
|
+
'# Values not listed here keep the chart defaults.',
|
|
43
|
+
'',
|
|
44
|
+
`replicaCount: ${choices.replicas === 'multi' ? 3 : 1}`,
|
|
45
|
+
'',
|
|
46
|
+
`secretName: ${yamlString(exports.HELM_SECRET_NAME)}`,
|
|
47
|
+
'',
|
|
48
|
+
'controlPlane: "true"',
|
|
49
|
+
`dbKeysMasterKeySource: ${yamlString(choices.masterKeySource)}`,
|
|
50
|
+
'# Also selects the CloudFront private-key source.',
|
|
51
|
+
`keysStorageType: ${yamlString(choices.masterKeySource === 'aws-secrets-manager' ? 'aws-secrets-manager' : 'environment')}`,
|
|
52
|
+
`storageMode: ${yamlString(choices.storage === 'gcs' ? 'gcs' : choices.storage === 'azure' ? 'azure' : 's3')}`,
|
|
53
|
+
`cacheMode: ${yamlString(choices.cacheMode)}`,
|
|
54
|
+
'useDashboard: "true"',
|
|
55
|
+
`useCloudfrontRedirect: "${choices.delivery === 'cloudfront'}"`,
|
|
56
|
+
`useGenericCDN: "${choices.delivery === 'generic-cdn'}"`,
|
|
57
|
+
`useAWSAccessKeys: "${(0, choices_1.needsAwsAccessKeys)(choices)}"`,
|
|
58
|
+
'',
|
|
59
|
+
];
|
|
60
|
+
if (choices.awsAuth === 'iam-role' &&
|
|
61
|
+
(choices.storage === 'aws-s3' || choices.masterKeySource === 'aws-secrets-manager')) {
|
|
62
|
+
lines.push('serviceAccount:', ' create: true', ' name: "xprem-sa"', ' # On EKS, bind the IAM role through the service account (IRSA):', ' annotations: {}', ' # eks.amazonaws.com/role-arn: "arn:aws:iam::ACCOUNT_ID:role/xprem-storage"', '');
|
|
63
|
+
}
|
|
64
|
+
lines.push('# Which env vars the pod gets. Values come from the secret; optional entries', `# are read from it only when present. Fill the values in ${exports.HELM_SECRETS_FILE}.`, 'environment:');
|
|
65
|
+
for (const section of envCatalog_1.ENV_SECTIONS) {
|
|
66
|
+
const vars = section.vars.filter(spec => spec.applies(choices) && !CHART_OWNED_VARS.has(spec.name));
|
|
67
|
+
for (const spec of vars) {
|
|
68
|
+
if (spec.helmKey) {
|
|
69
|
+
lines.push(` - name: ${yamlString(spec.name)}`, ` key: ${yamlString(spec.helmKey)}`, ' required: true', ' computed: true');
|
|
70
|
+
}
|
|
71
|
+
else if (spec.required) {
|
|
72
|
+
lines.push(` - name: ${yamlString(spec.name)}`, ' required: true');
|
|
73
|
+
}
|
|
74
|
+
else {
|
|
75
|
+
lines.push(` - name: ${yamlString(spec.name)}`, ' enabled: true', ' required: false');
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
lines.push('', 'podAnnotations: {}', ' # prometheus.io/scrape: "true"', '', 'ingress:', ' enabled: true', ' className: ""', ' hosts:', ` - host: ${yamlString(host)}`, ' paths:', ' - path: /', ' pathType: Prefix', ' tls:', ' - secretName: xprem-tls', ' hosts:', ` - ${yamlString(host)}`, '');
|
|
80
|
+
return lines.join('\n');
|
|
81
|
+
}
|
|
82
|
+
exports.renderHelmValues = renderHelmValues;
|
|
83
|
+
/**
|
|
84
|
+
* Renders the secrets.yaml content: a values overlay whose secretEnv map the
|
|
85
|
+
* chart turns into the secretName Secret. Every non-computed value lands
|
|
86
|
+
* here; optional vars stay commented out.
|
|
87
|
+
*/
|
|
88
|
+
function renderHelmSecretsValues(choices) {
|
|
89
|
+
const lines = [
|
|
90
|
+
'# Generated by eoas server:init. Do NOT commit this file; gitignore it.',
|
|
91
|
+
'# Fill every <placeholder>, then deploy it together with values.yaml:',
|
|
92
|
+
`# helm install xprem <chart> -f values.yaml -f ${exports.HELM_SECRETS_FILE} -n <namespace>`,
|
|
93
|
+
'# To change a value later, edit it here and run helm upgrade with the same',
|
|
94
|
+
'# flags: the chart re-rolls the pods automatically.',
|
|
95
|
+
'secretEnv:',
|
|
96
|
+
];
|
|
97
|
+
for (const section of envCatalog_1.ENV_SECTIONS) {
|
|
98
|
+
const vars = section.vars.filter(spec => spec.applies(choices) && !CHART_OWNED_VARS.has(spec.name) && !spec.helmKey);
|
|
99
|
+
const note = section.note?.(choices);
|
|
100
|
+
if (vars.length === 0) {
|
|
101
|
+
continue;
|
|
102
|
+
}
|
|
103
|
+
lines.push(` # ----- ${section.title} -----`);
|
|
104
|
+
if (note) {
|
|
105
|
+
lines.push(` # ${note}`);
|
|
106
|
+
}
|
|
107
|
+
for (const spec of vars) {
|
|
108
|
+
if (spec.comment) {
|
|
109
|
+
lines.push(` # ${spec.comment}`);
|
|
110
|
+
}
|
|
111
|
+
const assignment = `${spec.name}: ${yamlString(spec.value(choices))}`;
|
|
112
|
+
lines.push(spec.required ? ` ${assignment}` : ` # ${assignment}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
lines.push('');
|
|
116
|
+
return lines.join('\n');
|
|
117
|
+
}
|
|
118
|
+
exports.renderHelmSecretsValues = renderHelmSecretsValues;
|
|
119
|
+
/** The secretEnv map of a values document, when it carries one. */
|
|
120
|
+
function extractSecretEnv(doc) {
|
|
121
|
+
const secretEnv = doc.secretEnv;
|
|
122
|
+
if (!secretEnv || typeof secretEnv !== 'object' || Array.isArray(secretEnv)) {
|
|
123
|
+
return undefined;
|
|
124
|
+
}
|
|
125
|
+
const env = {};
|
|
126
|
+
for (const [key, value] of Object.entries(secretEnv)) {
|
|
127
|
+
env[key] = String(value ?? '');
|
|
128
|
+
}
|
|
129
|
+
return env;
|
|
130
|
+
}
|
|
131
|
+
exports.extractSecretEnv = extractSecretEnv;
|
|
132
|
+
function parseYamlFile(content) {
|
|
133
|
+
const parsed = (0, js_yaml_1.load)(content);
|
|
134
|
+
if (!parsed || typeof parsed !== 'object' || Array.isArray(parsed)) {
|
|
135
|
+
throw new Error('The file does not contain a YAML mapping.');
|
|
136
|
+
}
|
|
137
|
+
return parsed;
|
|
138
|
+
}
|
|
139
|
+
exports.parseYamlFile = parseYamlFile;
|
|
140
|
+
/** True for a YAML mapping that plausibly is a values file for the chart. */
|
|
141
|
+
function looksLikeChartValues(doc) {
|
|
142
|
+
return ['controlPlane', 'storageMode', 'cacheMode', 'secretName', 'environment'].some(key => key in doc);
|
|
143
|
+
}
|
|
144
|
+
exports.looksLikeChartValues = looksLikeChartValues;
|
|
145
|
+
/** The env the pod would see: the secret env plus the computed toggle vars. */
|
|
146
|
+
function envFromPair(values, secretEnv) {
|
|
147
|
+
const env = { ...secretEnv };
|
|
148
|
+
const entries = Array.isArray(values.environment) ? values.environment : [];
|
|
149
|
+
for (const entry of entries) {
|
|
150
|
+
if (entry && typeof entry === 'object' && entry.name && entry.computed && entry.key) {
|
|
151
|
+
env[entry.name] = String(values[entry.key] ?? '');
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
return env;
|
|
155
|
+
}
|
|
156
|
+
/**
|
|
157
|
+
* Validates a values/secret-env pair. Either side can be missing: the checks
|
|
158
|
+
* that need the other half are skipped and replaced with an issue saying so.
|
|
159
|
+
*/
|
|
160
|
+
function validateHelmPair(values, secretEnv) {
|
|
161
|
+
if (!values) {
|
|
162
|
+
return [
|
|
163
|
+
{
|
|
164
|
+
level: 'warning',
|
|
165
|
+
message: 'No chart values file found next to the secrets overlay: toggles are unknown, only the secret values themselves were checked.',
|
|
166
|
+
},
|
|
167
|
+
...validateSecretValues(secretEnv ?? {}),
|
|
168
|
+
];
|
|
169
|
+
}
|
|
170
|
+
if (String(values.controlPlane) !== 'true') {
|
|
171
|
+
return [
|
|
172
|
+
{
|
|
173
|
+
level: 'warning',
|
|
174
|
+
message: 'controlPlane is not "true": server:validate only checks control-plane configurations, nothing was verified.',
|
|
175
|
+
},
|
|
176
|
+
];
|
|
177
|
+
}
|
|
178
|
+
const issues = [];
|
|
179
|
+
if (!secretEnv) {
|
|
180
|
+
issues.push({
|
|
181
|
+
level: 'error',
|
|
182
|
+
message: `No ${exports.HELM_SECRETS_FILE} with a secretEnv map found next to the values file: it holds the environment values the chart renders into the "${String(values.secretName ?? exports.HELM_SECRET_NAME)}" Secret.`,
|
|
183
|
+
});
|
|
184
|
+
}
|
|
185
|
+
else {
|
|
186
|
+
issues.push(...(0, envCatalog_1.validateEnvMap)(envFromPair(values, secretEnv), {
|
|
187
|
+
masterKeySource: String(values.dbKeysMasterKeySource) === 'aws-secrets-manager'
|
|
188
|
+
? 'aws-secrets-manager'
|
|
189
|
+
: 'environment',
|
|
190
|
+
...(String(values.useCloudfrontRedirect) === 'true'
|
|
191
|
+
? { delivery: 'cloudfront' }
|
|
192
|
+
: String(values.useGenericCDN) === 'true'
|
|
193
|
+
? { delivery: 'generic-cdn' }
|
|
194
|
+
: {}),
|
|
195
|
+
}));
|
|
196
|
+
}
|
|
197
|
+
const replicaCount = Number(values.replicaCount ?? 1);
|
|
198
|
+
if (replicaCount > 1) {
|
|
199
|
+
if (String(values.cacheMode) === 'local') {
|
|
200
|
+
issues.push({
|
|
201
|
+
level: 'error',
|
|
202
|
+
message: `replicaCount is ${replicaCount} with cacheMode "local": replicas need a shared cache, use redis or redis-sentinel.`,
|
|
203
|
+
});
|
|
204
|
+
}
|
|
205
|
+
if (String(values.storageMode) === 'local') {
|
|
206
|
+
issues.push({
|
|
207
|
+
level: 'error',
|
|
208
|
+
message: `replicaCount is ${replicaCount} with storageMode "local": local storage cannot be shared between replicas.`,
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
const env = secretEnv ? envFromPair(values, secretEnv) : {};
|
|
213
|
+
if (String(values.useCloudfrontRedirect) !== 'true' && env.CLOUDFRONT_DOMAIN) {
|
|
214
|
+
issues.push({
|
|
215
|
+
level: 'warning',
|
|
216
|
+
message: 'CLOUDFRONT_DOMAIN is set but useCloudfrontRedirect is not "true".',
|
|
217
|
+
});
|
|
218
|
+
}
|
|
219
|
+
if (String(values.useGenericCDN) !== 'true' && env.CDN_BASE_URL) {
|
|
220
|
+
issues.push({
|
|
221
|
+
level: 'warning',
|
|
222
|
+
message: 'CDN_BASE_URL is set but useGenericCDN is not "true"; the chart will not render it.',
|
|
223
|
+
});
|
|
224
|
+
}
|
|
225
|
+
const ingress = values.ingress;
|
|
226
|
+
const host = ingress?.hosts?.[0]?.host;
|
|
227
|
+
if (host && env.BASE_URL && !(0, envCatalog_1.isPlaceholder)(env.BASE_URL) && !(0, envCatalog_1.isPlaceholder)(host)) {
|
|
228
|
+
try {
|
|
229
|
+
if (new URL(env.BASE_URL).hostname !== host) {
|
|
230
|
+
issues.push({
|
|
231
|
+
level: 'warning',
|
|
232
|
+
message: `BASE_URL (${env.BASE_URL}) does not match the ingress host (${host}).`,
|
|
233
|
+
});
|
|
234
|
+
}
|
|
235
|
+
}
|
|
236
|
+
catch {
|
|
237
|
+
// BASE_URL validity is already reported by validateEnvMap.
|
|
238
|
+
}
|
|
239
|
+
}
|
|
240
|
+
return issues;
|
|
241
|
+
}
|
|
242
|
+
exports.validateHelmPair = validateHelmPair;
|
|
243
|
+
/**
|
|
244
|
+
* Value-level checks for a secret env validated alone: without the values
|
|
245
|
+
* file the applicable variable set is unknown, so only what can be judged
|
|
246
|
+
* from the values themselves is checked.
|
|
247
|
+
*/
|
|
248
|
+
function validateSecretValues(env) {
|
|
249
|
+
const issues = [];
|
|
250
|
+
const lenientVars = new Set(envCatalog_1.ENV_SECTIONS.flatMap(section => section.vars.filter(spec => spec.lenient)).map(spec => spec.name));
|
|
251
|
+
for (const [name, value] of Object.entries(env)) {
|
|
252
|
+
if ((0, envCatalog_1.isPlaceholder)(value)) {
|
|
253
|
+
issues.push({
|
|
254
|
+
level: lenientVars.has(name) ? 'warning' : 'error',
|
|
255
|
+
message: `${name} is still a placeholder: ${value}`,
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
}
|
|
259
|
+
if (env.DB_KEYS_MASTER_KEY_B64 && env.AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID) {
|
|
260
|
+
issues.push({
|
|
261
|
+
level: 'error',
|
|
262
|
+
message: 'Both DB_KEYS_MASTER_KEY_B64 and AWSSM_DB_KEYS_MASTER_KEY_SECRET_ID are set; the server requires exactly one master key source.',
|
|
263
|
+
});
|
|
264
|
+
}
|
|
265
|
+
if (!!env.MAXMIND_ACCOUNT_ID !== !!env.MAXMIND_LICENSE_KEY) {
|
|
266
|
+
issues.push({
|
|
267
|
+
level: 'error',
|
|
268
|
+
message: 'MAXMIND_ACCOUNT_ID and MAXMIND_LICENSE_KEY must be set together; the server refuses to start with only one of them.',
|
|
269
|
+
});
|
|
270
|
+
}
|
|
271
|
+
if (env.ADMIN_PASSWORD && !(0, envCatalog_1.isPlaceholder)(env.ADMIN_PASSWORD)) {
|
|
272
|
+
const missing = (0, passwordPolicy_1.missingPasswordRules)(env.ADMIN_PASSWORD);
|
|
273
|
+
if (missing.length > 0) {
|
|
274
|
+
issues.push({
|
|
275
|
+
level: 'error',
|
|
276
|
+
message: `ADMIN_PASSWORD does not meet the dashboard password policy, it needs ${missing.join(', ')}. The first boot fails otherwise.`,
|
|
277
|
+
});
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
return issues;
|
|
281
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
// Mirror of the server-side password policy (internal/crypto/password.go),
|
|
3
|
+
// same as the dashboard mirror (apps/dashboard/src/lib/password-policy.ts).
|
|
4
|
+
// Keep the three in sync. Classes are unicode to match Go's
|
|
5
|
+
// unicode.IsUpper/IsLower/IsDigit, and length counts code points to match the
|
|
6
|
+
// server's rune count.
|
|
7
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
8
|
+
exports.missingPasswordRules = exports.PASSWORD_MIN_LENGTH = void 0;
|
|
9
|
+
exports.PASSWORD_MIN_LENGTH = 8;
|
|
10
|
+
const PASSWORD_RULES = [
|
|
11
|
+
{
|
|
12
|
+
label: `at least ${exports.PASSWORD_MIN_LENGTH} characters`,
|
|
13
|
+
test: password => [...password].length >= exports.PASSWORD_MIN_LENGTH,
|
|
14
|
+
},
|
|
15
|
+
{
|
|
16
|
+
label: 'an uppercase letter',
|
|
17
|
+
test: password => /\p{Lu}/u.test(password),
|
|
18
|
+
},
|
|
19
|
+
{
|
|
20
|
+
label: 'a lowercase letter',
|
|
21
|
+
test: password => /\p{Ll}/u.test(password),
|
|
22
|
+
},
|
|
23
|
+
{
|
|
24
|
+
label: 'a digit',
|
|
25
|
+
test: password => /\p{Nd}/u.test(password),
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
label: 'a special character',
|
|
29
|
+
test: password => /[^\p{Lu}\p{Ll}\p{Nd}]/u.test(password),
|
|
30
|
+
},
|
|
31
|
+
];
|
|
32
|
+
/** Returns the labels of every failing rule, empty when the password passes. */
|
|
33
|
+
function missingPasswordRules(password) {
|
|
34
|
+
return PASSWORD_RULES.filter(rule => !rule.test(password)).map(rule => rule.label);
|
|
35
|
+
}
|
|
36
|
+
exports.missingPasswordRules = missingPasswordRules;
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import { Credentials } from './auth';
|
|
2
|
+
export interface RuntimeVersionInfo {
|
|
3
|
+
runtimeVersion: string;
|
|
4
|
+
lastUpdatedAt: string;
|
|
5
|
+
createdAt: string;
|
|
6
|
+
numberOfUpdates: number;
|
|
7
|
+
}
|
|
8
|
+
export interface ServerUpdateItem {
|
|
9
|
+
updateUUID: string;
|
|
10
|
+
createdAt: string;
|
|
11
|
+
updateId: string;
|
|
12
|
+
platform: string;
|
|
13
|
+
commitHash: string;
|
|
14
|
+
message?: string;
|
|
15
|
+
publishGroup?: string;
|
|
16
|
+
}
|
|
17
|
+
export declare function fetchRuntimeVersions({ baseUrl, appId, branch, credentials, }: {
|
|
18
|
+
baseUrl: string;
|
|
19
|
+
appId: string;
|
|
20
|
+
branch: string;
|
|
21
|
+
credentials: Credentials;
|
|
22
|
+
}): Promise<RuntimeVersionInfo[]>;
|
|
23
|
+
export declare function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credentials, }: {
|
|
24
|
+
baseUrl: string;
|
|
25
|
+
appId: string;
|
|
26
|
+
branch: string;
|
|
27
|
+
runtimeVersion: string;
|
|
28
|
+
credentials: Credentials;
|
|
29
|
+
}): Promise<ServerUpdateItem[]>;
|
|
30
|
+
export interface PublishGroupSummary {
|
|
31
|
+
publishGroup: string;
|
|
32
|
+
platforms: string[];
|
|
33
|
+
commitHash: string;
|
|
34
|
+
message?: string;
|
|
35
|
+
createdAt: string;
|
|
36
|
+
updates: ServerUpdateItem[];
|
|
37
|
+
}
|
|
38
|
+
export declare function groupPublishedUpdates(updates: ServerUpdateItem[]): {
|
|
39
|
+
groups: PublishGroupSummary[];
|
|
40
|
+
ungrouped: ServerUpdateItem[];
|
|
41
|
+
};
|
|
42
|
+
export declare function describePublishGroup(group: PublishGroupSummary): {
|
|
43
|
+
title: string;
|
|
44
|
+
description: string;
|
|
45
|
+
};
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
+
exports.describePublishGroup = exports.groupPublishedUpdates = exports.fetchUpdates = exports.fetchRuntimeVersions = void 0;
|
|
4
|
+
const auth_1 = require("./auth");
|
|
5
|
+
const fetch_1 = require("./fetch");
|
|
6
|
+
async function fetchRuntimeVersions({ baseUrl, appId, branch, credentials, }) {
|
|
7
|
+
const response = await (0, fetch_1.fetchWithRetries)(`${baseUrl}/api/apps/${appId}/branch/${branch}/runtimeVersions`, {
|
|
8
|
+
headers: {
|
|
9
|
+
...(0, auth_1.getAuthHeaders)(credentials),
|
|
10
|
+
'use-cli-auth': 'true',
|
|
11
|
+
},
|
|
12
|
+
});
|
|
13
|
+
if (!response.ok) {
|
|
14
|
+
throw new Error(`Failed to fetch runtime versions: ${await response.text()}`);
|
|
15
|
+
}
|
|
16
|
+
return (await response.json());
|
|
17
|
+
}
|
|
18
|
+
exports.fetchRuntimeVersions = fetchRuntimeVersions;
|
|
19
|
+
async function fetchUpdates({ baseUrl, appId, branch, runtimeVersion, credentials, }) {
|
|
20
|
+
const response = await (0, fetch_1.fetchWithRetries)(`${baseUrl}/api/apps/${appId}/branch/${branch}/runtimeVersion/${runtimeVersion}/updates`, {
|
|
21
|
+
headers: {
|
|
22
|
+
...(0, auth_1.getAuthHeaders)(credentials),
|
|
23
|
+
'use-cli-auth': 'true',
|
|
24
|
+
},
|
|
25
|
+
});
|
|
26
|
+
if (!response.ok) {
|
|
27
|
+
throw new Error(`Failed to fetch updates: ${await response.text()}`);
|
|
28
|
+
}
|
|
29
|
+
return (await response.json());
|
|
30
|
+
}
|
|
31
|
+
exports.fetchUpdates = fetchUpdates;
|
|
32
|
+
// groupPublishedUpdates splits a listing into publish groups (newest first)
|
|
33
|
+
// and the leftover ungrouped updates (older CLIs, stateless servers). Filter
|
|
34
|
+
// out rollback markers before calling if they should not be offered.
|
|
35
|
+
function groupPublishedUpdates(updates) {
|
|
36
|
+
const groupsById = new Map();
|
|
37
|
+
const ungrouped = [];
|
|
38
|
+
for (const update of updates) {
|
|
39
|
+
if (!update.publishGroup) {
|
|
40
|
+
ungrouped.push(update);
|
|
41
|
+
continue;
|
|
42
|
+
}
|
|
43
|
+
const existing = groupsById.get(update.publishGroup);
|
|
44
|
+
if (!existing) {
|
|
45
|
+
groupsById.set(update.publishGroup, {
|
|
46
|
+
publishGroup: update.publishGroup,
|
|
47
|
+
platforms: [update.platform],
|
|
48
|
+
commitHash: update.commitHash,
|
|
49
|
+
message: update.message,
|
|
50
|
+
createdAt: update.createdAt,
|
|
51
|
+
updates: [update],
|
|
52
|
+
});
|
|
53
|
+
continue;
|
|
54
|
+
}
|
|
55
|
+
existing.updates.push(update);
|
|
56
|
+
if (!existing.platforms.includes(update.platform)) {
|
|
57
|
+
existing.platforms.push(update.platform);
|
|
58
|
+
}
|
|
59
|
+
// The freshest member dates the group in the picker.
|
|
60
|
+
if (update.createdAt > existing.createdAt) {
|
|
61
|
+
existing.createdAt = update.createdAt;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
const groups = [...groupsById.values()].sort((a, b) => b.createdAt.localeCompare(a.createdAt));
|
|
65
|
+
return { groups, ungrouped };
|
|
66
|
+
}
|
|
67
|
+
exports.groupPublishedUpdates = groupPublishedUpdates;
|
|
68
|
+
// A commit message can be a full paragraph; past this length the picker line
|
|
69
|
+
// wraps and drowns the platforms suffix.
|
|
70
|
+
const MAX_TITLE_MESSAGE_LENGTH = 48;
|
|
71
|
+
function truncateMessage(message) {
|
|
72
|
+
if (message.length <= MAX_TITLE_MESSAGE_LENGTH) {
|
|
73
|
+
return message;
|
|
74
|
+
}
|
|
75
|
+
return `${message.slice(0, MAX_TITLE_MESSAGE_LENGTH - 1).trimEnd()}…`;
|
|
76
|
+
}
|
|
77
|
+
// Compact deterministic timestamp (no locale, no seconds): publishes made in
|
|
78
|
+
// the same run are seconds apart, minute precision is enough to tell runs
|
|
79
|
+
// apart without flooding the line.
|
|
80
|
+
function formatPublishedAt(createdAt) {
|
|
81
|
+
const parsed = new Date(createdAt);
|
|
82
|
+
if (Number.isNaN(parsed.getTime())) {
|
|
83
|
+
return createdAt;
|
|
84
|
+
}
|
|
85
|
+
return `${parsed.toISOString().slice(0, 16).replace('T', ' ')} UTC`;
|
|
86
|
+
}
|
|
87
|
+
// describePublishGroup renders one picker entry: a truncated message (or
|
|
88
|
+
// commit) plus the platforms as the title, and each sub-update with its
|
|
89
|
+
// platform and release time as the description.
|
|
90
|
+
function describePublishGroup(group) {
|
|
91
|
+
// A publish made outside a git repository stores an empty commit hash; fall
|
|
92
|
+
// back to the date so the picker never renders an empty label.
|
|
93
|
+
const shortCommit = group.commitHash.slice(0, 7);
|
|
94
|
+
const label = group.message?.trim()
|
|
95
|
+
? truncateMessage(group.message.trim())
|
|
96
|
+
: shortCommit
|
|
97
|
+
? `Commit ${shortCommit}`
|
|
98
|
+
: `Published ${formatPublishedAt(group.createdAt)}`;
|
|
99
|
+
const members = group.updates
|
|
100
|
+
.map(update => `${update.platform} ${formatPublishedAt(update.createdAt)}`)
|
|
101
|
+
.join(', ');
|
|
102
|
+
const commitSuffix = shortCommit ? ` (commit ${shortCommit})` : '';
|
|
103
|
+
return {
|
|
104
|
+
title: `${label} (${group.platforms.join(' + ')})`,
|
|
105
|
+
description: `${members}${commitSuffix}`,
|
|
106
|
+
};
|
|
107
|
+
}
|
|
108
|
+
exports.describePublishGroup = describePublishGroup;
|
package/dist/lib/utils.d.ts
CHANGED
package/dist/lib/utils.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
"use strict";
|
|
2
2
|
Object.defineProperty(exports, "__esModule", { value: true });
|
|
3
|
-
exports.ensurePrivateKeyIgnored = exports.isValidUpdateUrl = void 0;
|
|
3
|
+
exports.ensurePrivateKeyIgnored = exports.ensureGitIgnored = exports.isValidUpdateUrl = void 0;
|
|
4
4
|
const tslib_1 = require("tslib");
|
|
5
5
|
const fs_extra_1 = tslib_1.__importDefault(require("fs-extra"));
|
|
6
6
|
const path_1 = tslib_1.__importDefault(require("path"));
|
|
@@ -9,30 +9,34 @@ function isValidUpdateUrl(updateUrl) {
|
|
|
9
9
|
return updateUrl.match(/^https?:\/\/[^/]+$/) !== null;
|
|
10
10
|
}
|
|
11
11
|
exports.isValidUpdateUrl = isValidUpdateUrl;
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
//
|
|
16
|
-
//
|
|
17
|
-
//
|
|
18
|
-
function
|
|
12
|
+
// Appends pattern to the project .gitignore unless an identical rule is already
|
|
13
|
+
// in force. Only an exact existing rule counts: comments, negated entries and
|
|
14
|
+
// rules that differ in path do not guarantee the same protection. Appending at
|
|
15
|
+
// the end also wins over an earlier negated entry, since the last matching
|
|
16
|
+
// gitignore rule prevails. A pattern without a slash matches at every directory
|
|
17
|
+
// level; one with a slash is relative to the .gitignore.
|
|
18
|
+
function ensureGitIgnored(projectDir, pattern, reason) {
|
|
19
19
|
const gitignorePath = path_1.default.join(projectDir, '.gitignore');
|
|
20
20
|
try {
|
|
21
21
|
// eslint-disable-next-line node/no-sync
|
|
22
22
|
const gitignore = fs_extra_1.default.existsSync(gitignorePath) ? fs_extra_1.default.readFileSync(gitignorePath, 'utf8') : '';
|
|
23
23
|
const lines = gitignore.split(/\r?\n/).map(line => line.trim());
|
|
24
|
-
const
|
|
25
|
-
const lastNegation = lines.lastIndexOf(
|
|
26
|
-
if (
|
|
24
|
+
const lastRule = lines.lastIndexOf(pattern);
|
|
25
|
+
const lastNegation = lines.lastIndexOf(`!${pattern}`);
|
|
26
|
+
if (lastRule !== -1 && lastRule > lastNegation) {
|
|
27
27
|
return;
|
|
28
28
|
}
|
|
29
29
|
const separator = gitignore === '' ? '' : gitignore.endsWith('\n') ? '\n' : '\n\n';
|
|
30
30
|
// eslint-disable-next-line node/no-sync
|
|
31
|
-
fs_extra_1.default.appendFileSync(gitignorePath, `${separator}#
|
|
32
|
-
log_1.default.succeed(
|
|
31
|
+
fs_extra_1.default.appendFileSync(gitignorePath, `${separator}# ${reason}\n${pattern}\n`);
|
|
32
|
+
log_1.default.succeed(`Added ${pattern} to .gitignore`);
|
|
33
33
|
}
|
|
34
34
|
catch {
|
|
35
|
-
log_1.default.warn(
|
|
35
|
+
log_1.default.warn(`Could not update .gitignore. Make sure ${pattern} is never committed to your repository.`);
|
|
36
36
|
}
|
|
37
37
|
}
|
|
38
|
+
exports.ensureGitIgnored = ensureGitIgnored;
|
|
39
|
+
function ensurePrivateKeyIgnored(projectDir) {
|
|
40
|
+
ensureGitIgnored(projectDir, 'private-key.pem', 'Code signing private key (server-side secret, never commit it)');
|
|
41
|
+
}
|
|
38
42
|
exports.ensurePrivateKeyIgnored = ensurePrivateKeyIgnored;
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "eoas",
|
|
3
|
-
"version": "3.0
|
|
3
|
+
"version": "3.1.0",
|
|
4
4
|
"main": "index.js",
|
|
5
5
|
"scripts": {
|
|
6
6
|
"build": "tsc --project tsconfig.json",
|
|
@@ -11,21 +11,22 @@
|
|
|
11
11
|
"engines": {
|
|
12
12
|
"node": ">=18.0.0"
|
|
13
13
|
},
|
|
14
|
-
"homepage": "https://github.com/mercuretechnologies/
|
|
14
|
+
"homepage": "https://github.com/mercuretechnologies/xprem/tree/main/eoas",
|
|
15
15
|
"keywords": [
|
|
16
|
-
"
|
|
16
|
+
"xprem",
|
|
17
17
|
"expo",
|
|
18
18
|
"eas",
|
|
19
19
|
"cli"
|
|
20
20
|
],
|
|
21
21
|
"author": "Axel Marciano",
|
|
22
22
|
"license": "MIT",
|
|
23
|
-
"description": "A CLI tool to manage publishing and OTA updates for
|
|
23
|
+
"description": "A CLI tool to manage publishing and OTA updates for the xprem self-hosted server. This is not an official tool from Expo but an open-source project (https://github.com/mercuretechnologies/xprem)",
|
|
24
24
|
"repository": {
|
|
25
25
|
"type": "git",
|
|
26
|
-
"url": "git+https://github.com/mercuretechnologies/
|
|
26
|
+
"url": "git+https://github.com/mercuretechnologies/xprem.git"
|
|
27
27
|
},
|
|
28
28
|
"dependencies": {
|
|
29
|
+
"@clack/prompts": "^0.11.0",
|
|
29
30
|
"@expo/code-signing-certificates": "^0.0.5",
|
|
30
31
|
"@expo/config": "10.0.11",
|
|
31
32
|
"@expo/config-plugins": "9.0.12",
|
|
@@ -52,11 +53,11 @@
|
|
|
52
53
|
"https-proxy-agent": "5.0.1",
|
|
53
54
|
"ignore": "5.3.0",
|
|
54
55
|
"joi": "17.11.0",
|
|
56
|
+
"js-yaml": "^5.2.2",
|
|
55
57
|
"jscodeshift": "^17.1.2",
|
|
56
58
|
"log-symbols": "^4.0.0",
|
|
57
59
|
"mime": "3.0.0",
|
|
58
60
|
"node-fetch": "^2.6.7",
|
|
59
|
-
"ora": "^5.1.0",
|
|
60
61
|
"prettier": "3.1.1",
|
|
61
62
|
"prompts": "^2.4.2",
|
|
62
63
|
"recast": "^0.23.9",
|
|
@@ -72,6 +73,7 @@
|
|
|
72
73
|
"@tsconfig/node18": "^18.2.4",
|
|
73
74
|
"@types/fs-extra": "11.0.4",
|
|
74
75
|
"@types/getenv": "^1.0.0",
|
|
76
|
+
"@types/js-yaml": "^4.0.9",
|
|
75
77
|
"@types/jscodeshift": "^0.12.0",
|
|
76
78
|
"@types/mime": "^3.0.4",
|
|
77
79
|
"@types/node": "^18.19.74",
|