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.
@@ -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,3 @@
1
+ export declare const PASSWORD_MIN_LENGTH = 8;
2
+ /** Returns the labels of every failing rule, empty when the password passes. */
3
+ export declare function missingPasswordRules(password: string): string[];
@@ -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;
@@ -1,2 +1,3 @@
1
1
  export declare function isValidUpdateUrl(updateUrl: string): boolean;
2
+ export declare function ensureGitIgnored(projectDir: string, pattern: string, reason: string): void;
2
3
  export declare function ensurePrivateKeyIgnored(projectDir: string): void;
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
- // Keeps the code signing private key out of the app repository: appends a bare
13
- // 'private-key.pem' pattern to the project .gitignore (a pattern without a
14
- // slash matches at every directory level). Only an existing bare rule counts:
15
- // comments, negated entries and path-specific rules like certs/private-key.pem
16
- // do not guarantee project-wide protection. Appending at the end also wins over
17
- // an earlier negated entry, since the last matching gitignore rule prevails.
18
- function ensurePrivateKeyIgnored(projectDir) {
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 lastBareRule = lines.lastIndexOf('private-key.pem');
25
- const lastNegation = lines.lastIndexOf('!private-key.pem');
26
- if (lastBareRule !== -1 && lastBareRule > lastNegation) {
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}# Code signing private key (server-side secret, never commit it)\nprivate-key.pem\n`);
32
- log_1.default.succeed('Added private-key.pem to .gitignore');
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('Could not update .gitignore. Make sure private-key.pem is never committed to your repository.');
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.5",
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/expo-open-ota/tree/main/eoas",
14
+ "homepage": "https://github.com/mercuretechnologies/xprem/tree/main/eoas",
15
15
  "keywords": [
16
- "expo-open-ota",
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 expo-open-OTA self-hosted server. This is not an official tool from Expo but an open-source project (https://github.com/mercuretechnologies/expo-open-ota)",
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/expo-open-ota.git"
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",