@occam-scaly/scaly-cli 0.2.4 → 0.2.6

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/bin/scaly.js CHANGED
@@ -78,7 +78,8 @@ Usage:
78
78
  scaly db shell --addon <addOnId> [--ttl-minutes 60] [--local-port 5432] [--host 127.0.0.1]
79
79
  scaly db schema dump --addon <addOnId> [--out .scaly/schema.sql] [--ttl-minutes 60]
80
80
  scaly db migrate <sql-file> --addon <addOnId> [--ttl-minutes 60] [--yes]
81
- scaly deploy --app <appId> [--watch] [--strategy auto|git|restart] [--json]
81
+ scaly deploy --app <appId> [--watch] [--strategy auto|git|restart|upload] [--json]
82
+ - upload flags: [--path <dir>] [--preview] [--yes] [--allow-unsafe] [--max-bytes N] [--max-files N]
82
83
  scaly logs --follow --app <appId> [--since 10m] [--level error|warn|info|debug|all] [--q <str>] [--duration-seconds N] [--max-lines N] [--json]
83
84
  scaly accounts create --email <email> [--name <org>] [--region EU|US|CANADA|ASIA_PACIFIC]
84
85
  scaly stacks create --account <id> --name <stackName> [--size Eco|Basic|...] [--min-idle N]
@@ -1856,9 +1857,15 @@ async function runDeploy(rest) {
1856
1857
  const f = parseKv(rest);
1857
1858
  const json = parseBool(f.json, false);
1858
1859
  const watch = f.watch !== undefined ? parseBool(f.watch, true) : false;
1859
- const strategy = String(f.strategy || 'auto').toLowerCase(); // auto|git|restart
1860
+ const strategy = String(f.strategy || 'auto').toLowerCase(); // auto|git|restart|upload
1860
1861
  const pollSeconds = Number(f['poll-seconds'] || 5);
1861
1862
  const timeoutMinutes = Number(f['timeout-minutes'] || 20);
1863
+ const preview = parseBool(f.preview, false);
1864
+ const yes = parseBool(f.yes, false);
1865
+ const allowUnsafe = parseBool(f['allow-unsafe'], false);
1866
+ const maxBytes = f['max-bytes'] != null ? Number(f['max-bytes']) : undefined;
1867
+ const maxFiles = f['max-files'] != null ? Number(f['max-files']) : undefined;
1868
+ const uploadPath = f.path || f['project-path'] || f.project_path || '.';
1862
1869
 
1863
1870
  const appId = f.app || f['app-id'] || (f._ && f._[0]);
1864
1871
  if (!appId) {
@@ -1964,6 +1971,140 @@ async function runDeploy(rest) {
1964
1971
  return ok ? 0 : 1;
1965
1972
  }
1966
1973
 
1974
+ if (chosen === 'upload') {
1975
+ const path = require('path');
1976
+ const readline = require('readline');
1977
+ const artifacts = require('../lib/scaly-artifacts');
1978
+
1979
+ const rootPath = path.resolve(process.cwd(), String(uploadPath || '.'));
1980
+ const scalyIgnorePath = path.join(rootPath, '.scalyignore');
1981
+
1982
+ let plan;
1983
+ try {
1984
+ plan = await artifacts.planDirectoryUpload({
1985
+ rootPath,
1986
+ scalyIgnorePath,
1987
+ maxBytes,
1988
+ maxFiles,
1989
+ allowUnsafe
1990
+ });
1991
+ } catch (e) {
1992
+ const msg = String(e && e.message ? e.message : e);
1993
+ const out = {
1994
+ ok: false,
1995
+ strategy: 'upload',
1996
+ error: { message: msg, code: e && e.code, details: e && e.details }
1997
+ };
1998
+ if (json) console.log(JSON.stringify(out));
1999
+ else console.error(msg);
2000
+ return 2;
2001
+ }
2002
+
2003
+ if (preview) {
2004
+ const out = { ok: true, strategy: 'upload', preview: true, app, plan };
2005
+ if (json) console.log(JSON.stringify(out));
2006
+ else {
2007
+ console.log(`[deploy] preview upload path=${rootPath}`);
2008
+ console.log(
2009
+ `[deploy] included=${plan.included_count} excluded=${plan.excluded_count} bytes=${plan.total_bytes}`
2010
+ );
2011
+ console.log(`[deploy] preview_hash=${plan.preview_hash}`);
2012
+ if (plan.scalyignore_path)
2013
+ console.log(`[deploy] using .scalyignore: ${plan.scalyignore_path}`);
2014
+ }
2015
+ return 0;
2016
+ }
2017
+
2018
+ if (!yes) {
2019
+ const rl = readline.createInterface({
2020
+ input: process.stdin,
2021
+ output: process.stdout
2022
+ });
2023
+ const answer = await new Promise((resolve) =>
2024
+ rl.question(
2025
+ `Upload ${plan.included_count} files (${plan.total_bytes} bytes) for app ${app.name}? (y/N) `,
2026
+ resolve
2027
+ )
2028
+ );
2029
+ rl.close();
2030
+ if (!/^y(es)?$/i.test(String(answer || '').trim())) {
2031
+ if (json) console.log(JSON.stringify({ ok: false, aborted: true }));
2032
+ else console.error('Aborted.');
2033
+ return 1;
2034
+ }
2035
+ }
2036
+
2037
+ const tmpZip = artifacts.defaultTempZipPath({ appId });
2038
+ let zipInfo;
2039
+ try {
2040
+ zipInfo = await artifacts.createZip({
2041
+ rootPath,
2042
+ files: plan.files,
2043
+ outPath: tmpZip
2044
+ });
2045
+ } catch (e) {
2046
+ const msg = String(e && e.message ? e.message : e);
2047
+ if (json)
2048
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2049
+ else console.error(msg);
2050
+ return 1;
2051
+ }
2052
+
2053
+ let link;
2054
+ try {
2055
+ link = await deploy.createAppUploadLink(appId);
2056
+ } catch (e) {
2057
+ const msg = String(e && e.message ? e.message : e);
2058
+ if (json)
2059
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2060
+ else console.error(msg);
2061
+ return 1;
2062
+ }
2063
+ if (!link || !link.url) {
2064
+ const msg = 'Failed to create upload link (missing url).';
2065
+ if (json)
2066
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2067
+ else console.error(msg);
2068
+ return 1;
2069
+ }
2070
+
2071
+ try {
2072
+ await artifacts.uploadToSignedUrl({
2073
+ url: link.url,
2074
+ filePath: zipInfo.out_path
2075
+ });
2076
+ } catch (e) {
2077
+ const msg = String(e && e.message ? e.message : e);
2078
+ if (json)
2079
+ console.log(JSON.stringify({ ok: false, error: { message: msg } }));
2080
+ else console.error(msg);
2081
+ return 1;
2082
+ }
2083
+
2084
+ const out = {
2085
+ ok: true,
2086
+ strategy: 'upload',
2087
+ app,
2088
+ upload: { bytes_uploaded: zipInfo.bytes_written, sha256: zipInfo.sha256 },
2089
+ artifact: { path: zipInfo.out_path },
2090
+ plan: {
2091
+ included_count: plan.included_count,
2092
+ excluded_count: plan.excluded_count,
2093
+ total_bytes: plan.total_bytes,
2094
+ preview_hash: plan.preview_hash
2095
+ }
2096
+ };
2097
+
2098
+ if (json) console.log(JSON.stringify(out));
2099
+ else
2100
+ console.log(
2101
+ `[deploy] uploaded ${zipInfo.bytes_written} bytes (${zipInfo.sha256})`
2102
+ );
2103
+
2104
+ // Upload triggers app version + deployment server-side.
2105
+ return 0;
2106
+ }
2107
+
1967
2108
  // restart strategy
1968
2109
  const dep = await deploy.restartStackServices(app.stackId);
1969
2110
  if (!dep?.id) {
package/lib/scaly-api.js CHANGED
@@ -254,6 +254,27 @@ const QUERIES = {
254
254
  }
255
255
  }
256
256
  `,
257
+ listAddOnsByType: `
258
+ query ListAddOnsByType($accountId: String!, $type: AddOnTypeEnum!, $take: Int) {
259
+ listAddOns(
260
+ where: {
261
+ AND: [
262
+ { accountId: { equals: $accountId } }
263
+ { type: { equals: $type } }
264
+ { isDeleted: { equals: false } }
265
+ ]
266
+ }
267
+ take: $take
268
+ ) {
269
+ id
270
+ name
271
+ type
272
+ accountId
273
+ status
274
+ addOnCognito { userPoolName }
275
+ }
276
+ }
277
+ `,
257
278
  listStacks: `
258
279
  query ListStacks($take: Int) {
259
280
  listStacks(
@@ -351,6 +372,15 @@ async function findAddOnByName({ name, accountId }) {
351
372
  return data?.listAddOns || [];
352
373
  }
353
374
 
375
+ async function listAddOnsByType({ accountId, type, take = 10 }) {
376
+ const data = await graphqlRequest(QUERIES.listAddOnsByType, {
377
+ accountId,
378
+ type,
379
+ take
380
+ });
381
+ return data?.listAddOns || [];
382
+ }
383
+
354
384
  async function createStack({ accountId, name, size, minIdle }) {
355
385
  const data = await graphqlRequest(MUTATIONS.createStack, {
356
386
  data: {
@@ -419,6 +449,7 @@ module.exports = {
419
449
  findStackByName,
420
450
  findAppByName,
421
451
  findAddOnByName,
452
+ listAddOnsByType,
422
453
  listStacks,
423
454
  listApps,
424
455
  createStack,
@@ -0,0 +1,295 @@
1
+ 'use strict';
2
+
3
+ const crypto = require('crypto');
4
+ const fs = require('fs');
5
+ const os = require('os');
6
+ const path = require('path');
7
+
8
+ const fg = require('fast-glob');
9
+ const ignore = require('ignore');
10
+ const archiver = require('archiver');
11
+
12
+ const DEFAULT_MAX_BYTES = 100 * 1024 * 1024; // 100MB
13
+ const DEFAULT_MAX_FILES = 25_000;
14
+
15
+ const SAFE_DEFAULT_IGNORE = [
16
+ '.git/',
17
+ 'node_modules/',
18
+ '.venv/',
19
+ 'venv/',
20
+ '__pycache__/',
21
+ '.pytest_cache/',
22
+ '.mypy_cache/',
23
+ '.ruff_cache/',
24
+ '.next/',
25
+ '.turbo/',
26
+ 'dist/',
27
+ 'build/',
28
+ '.DS_Store'
29
+ ];
30
+
31
+ const SENSITIVE_PATH_PATTERNS = [
32
+ // dotenv
33
+ /(^|\/)\.env(\..*)?$/i,
34
+ // ssh / aws creds
35
+ /(^|\/)\.ssh(\/|$)/i,
36
+ /(^|\/)\.aws(\/|$)/i,
37
+ // common package registry credentials
38
+ /(^|\/)\.npmrc$/i,
39
+ /(^|\/)\.pypirc$/i,
40
+ // docker registry auth
41
+ /(^|\/)\.docker\/config\.json$/i,
42
+ // kubernetes creds
43
+ /(^|\/)\.kube(\/|$)/i,
44
+ /(^|\/)(kubeconfig|.*\.kubeconfig)$/i,
45
+ // terraform state (often contains secrets)
46
+ /(^|\/).*\.tfstate(\.backup)?$/i,
47
+ /(^|\/)terraform\.tfstate(\.backup)?$/i,
48
+ // GCP service account keys / generic credentials files
49
+ /(^|\/)credentials\.json$/i,
50
+ /(^|\/)service-account.*\.json$/i,
51
+ // keystores / pkcs
52
+ /\.p12$/i,
53
+ /\.pfx$/i,
54
+ /\.keystore$/i,
55
+ /\.jks$/i,
56
+ // generic "secrets" files (defense-in-depth; can be overridden)
57
+ /(^|\/).*secrets?.*\.(ya?ml|json)$/i,
58
+ // private keys
59
+ /\.pem$/i,
60
+ /\.key$/i,
61
+ /id_rsa/i,
62
+ /id_ed25519/i
63
+ ];
64
+
65
+ function isSensitivePath(relPath) {
66
+ const p = String(relPath || '').replace(/\\/g, '/');
67
+ return SENSITIVE_PATH_PATTERNS.some((re) => re.test(p));
68
+ }
69
+
70
+ function readLinesIfExists(filePath) {
71
+ try {
72
+ if (!fs.existsSync(filePath)) return [];
73
+ const text = fs.readFileSync(filePath, 'utf8');
74
+ return text
75
+ .split(/\r?\n/g)
76
+ .map((l) => l.trim())
77
+ .filter((l) => l && !l.startsWith('#'));
78
+ } catch {
79
+ return [];
80
+ }
81
+ }
82
+
83
+ function buildIgnoreMatcher({ rootPath, scalyIgnorePath }) {
84
+ const ig = ignore();
85
+ ig.add(SAFE_DEFAULT_IGNORE);
86
+ const scalyIgnoreLines = readLinesIfExists(scalyIgnorePath);
87
+ if (scalyIgnoreLines.length) ig.add(scalyIgnoreLines);
88
+ return { ig, scalyIgnoreLines };
89
+ }
90
+
91
+ async function planDirectoryUpload({
92
+ rootPath,
93
+ scalyIgnorePath,
94
+ maxBytes = DEFAULT_MAX_BYTES,
95
+ maxFiles = DEFAULT_MAX_FILES,
96
+ allowUnsafe = false
97
+ }) {
98
+ const absRoot = path.resolve(String(rootPath || '.'));
99
+ const stat = fs.statSync(absRoot);
100
+ if (!stat.isDirectory()) {
101
+ const e = new Error(`Not a directory: ${absRoot}`);
102
+ e.code = 'SCALY_ARTIFACT_NOT_DIR';
103
+ throw e;
104
+ }
105
+
106
+ const { ig, scalyIgnoreLines } = buildIgnoreMatcher({
107
+ rootPath: absRoot,
108
+ scalyIgnorePath
109
+ });
110
+
111
+ const entries = await fg(['**/*'], {
112
+ cwd: absRoot,
113
+ dot: true,
114
+ onlyFiles: true,
115
+ followSymbolicLinks: false
116
+ });
117
+
118
+ if (entries.length > maxFiles) {
119
+ const e = new Error(
120
+ `Too many files to upload (${entries.length} > ${maxFiles}). Add exclusions to .scalyignore or increase --max-files.`
121
+ );
122
+ e.code = 'SCALY_ARTIFACT_TOO_MANY_FILES';
123
+ throw e;
124
+ }
125
+
126
+ const included = [];
127
+ const excluded = [];
128
+ const blockedSensitive = [];
129
+ let totalBytes = 0;
130
+ const largest = [];
131
+
132
+ for (const rel of entries) {
133
+ const relPosix = String(rel).replace(/\\/g, '/');
134
+
135
+ if (ig.ignores(relPosix)) {
136
+ excluded.push(relPosix);
137
+ continue;
138
+ }
139
+
140
+ const abs = path.join(absRoot, rel);
141
+ const st = fs.lstatSync(abs);
142
+ if (st.isSymbolicLink()) {
143
+ excluded.push(relPosix);
144
+ continue;
145
+ }
146
+
147
+ if (!allowUnsafe && isSensitivePath(relPosix)) {
148
+ blockedSensitive.push(relPosix);
149
+ continue;
150
+ }
151
+
152
+ const size = st.size || 0;
153
+ totalBytes += size;
154
+ included.push({ path: relPosix, bytes: size });
155
+
156
+ if (largest.length < 10) {
157
+ largest.push({ path: relPosix, bytes: size });
158
+ largest.sort((a, b) => b.bytes - a.bytes);
159
+ } else if (size > largest[largest.length - 1].bytes) {
160
+ largest[largest.length - 1] = { path: relPosix, bytes: size };
161
+ largest.sort((a, b) => b.bytes - a.bytes);
162
+ }
163
+
164
+ if (totalBytes > maxBytes) {
165
+ const e = new Error(
166
+ `Upload too large (${totalBytes} bytes > ${maxBytes}). Add exclusions to .scalyignore, lower artifacts, or increase --max-bytes.`
167
+ );
168
+ e.code = 'SCALY_ARTIFACT_TOO_LARGE';
169
+ e.details = { total_bytes: totalBytes, max_bytes: maxBytes };
170
+ throw e;
171
+ }
172
+ }
173
+
174
+ if (!allowUnsafe && blockedSensitive.length) {
175
+ const e = new Error(
176
+ `Refusing to upload ${blockedSensitive.length} potentially sensitive files. Add them to .scalyignore or re-run with --allow-unsafe.`
177
+ );
178
+ e.code = 'SCALY_ARTIFACT_SENSITIVE';
179
+ e.details = { blocked: blockedSensitive.slice(0, 50) };
180
+ throw e;
181
+ }
182
+
183
+ const previewHash = crypto
184
+ .createHash('sha256')
185
+ .update(
186
+ JSON.stringify(
187
+ included
188
+ .slice()
189
+ .sort((a, b) => a.path.localeCompare(b.path))
190
+ .map((f) => [f.path, f.bytes])
191
+ )
192
+ )
193
+ .digest('hex');
194
+
195
+ return {
196
+ root: absRoot,
197
+ scalyignore_path:
198
+ scalyIgnorePath && fs.existsSync(scalyIgnorePath)
199
+ ? scalyIgnorePath
200
+ : null,
201
+ scalyignore_rules: scalyIgnoreLines,
202
+ safe_default_rules: SAFE_DEFAULT_IGNORE,
203
+ allow_unsafe: !!allowUnsafe,
204
+ included_count: included.length,
205
+ excluded_count: excluded.length,
206
+ total_bytes: totalBytes,
207
+ largest_files: largest,
208
+ preview_hash: `sha256:${previewHash}`,
209
+ files: included
210
+ };
211
+ }
212
+
213
+ async function createZip({ rootPath, files, outPath }) {
214
+ const absRoot = path.resolve(String(rootPath || '.'));
215
+ const absOut = path.resolve(String(outPath));
216
+ fs.mkdirSync(path.dirname(absOut), { recursive: true });
217
+
218
+ const output = fs.createWriteStream(absOut);
219
+ const archive = archiver('zip', { zlib: { level: 9 } });
220
+ const hash = crypto.createHash('sha256');
221
+ let bytesWritten = 0;
222
+
223
+ const done = new Promise((resolve, reject) => {
224
+ output.on('close', () =>
225
+ resolve({
226
+ out_path: absOut,
227
+ bytes_written: bytesWritten,
228
+ sha256: `sha256:${hash.digest('hex')}`
229
+ })
230
+ );
231
+ output.on('error', reject);
232
+ archive.on('error', reject);
233
+ });
234
+
235
+ archive.on('data', (chunk) => {
236
+ bytesWritten += chunk.length;
237
+ hash.update(chunk);
238
+ });
239
+
240
+ archive.pipe(output);
241
+
242
+ for (const entry of files || []) {
243
+ const rel = typeof entry === 'string' ? entry : entry.path;
244
+ if (!rel) continue;
245
+ const abs = path.join(absRoot, rel);
246
+ archive.file(abs, { name: rel });
247
+ }
248
+
249
+ await archive.finalize();
250
+ return await done;
251
+ }
252
+
253
+ function defaultTempZipPath({ appId }) {
254
+ const safe = String(appId || 'app')
255
+ .replace(/[^a-zA-Z0-9._-]/g, '_')
256
+ .slice(0, 64);
257
+ return path.join(os.tmpdir(), `scaly-upload-${safe}-${Date.now()}.zip`);
258
+ }
259
+
260
+ async function uploadToSignedUrl({
261
+ url,
262
+ filePath,
263
+ contentType = 'application/zip'
264
+ }) {
265
+ const axios = require('axios');
266
+ const abs = path.resolve(String(filePath));
267
+ const st = fs.statSync(abs);
268
+ const stream = fs.createReadStream(abs);
269
+ const res = await axios.put(url, stream, {
270
+ headers: {
271
+ 'content-type': contentType,
272
+ 'content-length': st.size
273
+ },
274
+ maxBodyLength: Infinity,
275
+ maxContentLength: Infinity,
276
+ timeout: 10 * 60_000,
277
+ validateStatus: () => true
278
+ });
279
+ if (res.status < 200 || res.status >= 300) {
280
+ const e = new Error(`Upload failed (HTTP ${res.status})`);
281
+ e.code = 'SCALY_UPLOAD_FAILED';
282
+ throw e;
283
+ }
284
+ return { ok: true, status: res.status, bytes_uploaded: st.size };
285
+ }
286
+
287
+ module.exports = {
288
+ DEFAULT_MAX_BYTES,
289
+ DEFAULT_MAX_FILES,
290
+ SAFE_DEFAULT_IGNORE,
291
+ planDirectoryUpload,
292
+ createZip,
293
+ defaultTempZipPath,
294
+ uploadToSignedUrl
295
+ };
@@ -36,6 +36,12 @@ const TRIGGER_GIT_DEPLOY = `
36
36
  }
37
37
  `;
38
38
 
39
+ const CREATE_APP_UPLOAD_LINK = `
40
+ mutation CreateAppUploadLink($where: AppWhereUniqueInput!) {
41
+ createAppUploadLink(where: $where) { url }
42
+ }
43
+ `;
44
+
39
45
  const LIST_GIT_DEPLOYMENTS = `
40
46
  query ListGitDeployments($appId: String!, $limit: Int) {
41
47
  listGitDeployments(appId: $appId, limit: $limit) {
@@ -110,6 +116,13 @@ async function triggerGitDeploy(appId) {
110
116
  return data?.triggerGitDeploy || null;
111
117
  }
112
118
 
119
+ async function createAppUploadLink(appId) {
120
+ const data = await api.graphqlRequest(CREATE_APP_UPLOAD_LINK, {
121
+ where: { id: appId }
122
+ });
123
+ return data?.createAppUploadLink || null;
124
+ }
125
+
113
126
  async function listGitDeployments(appId, limit = 10) {
114
127
  const data = await api.graphqlRequest(LIST_GIT_DEPLOYMENTS, { appId, limit });
115
128
  return data?.listGitDeployments || [];
@@ -131,6 +144,7 @@ module.exports = {
131
144
  getAppBasic,
132
145
  getAppGitSource,
133
146
  triggerGitDeploy,
147
+ createAppUploadLink,
134
148
  listGitDeployments,
135
149
  restartStackServices,
136
150
  getDeployment
package/lib/scaly-plan.js CHANGED
@@ -122,6 +122,12 @@ async function buildPlan({ config, env, appName }) {
122
122
  accountId = await resolveAccountId({ config });
123
123
  }
124
124
 
125
+ const requestedAuthPools = new Set();
126
+ for (const a of filteredApps) {
127
+ const pool = a && a.auth && a.auth.userPool;
128
+ if (typeof pool === 'string' && pool.trim()) requestedAuthPools.add(pool);
129
+ }
130
+
125
131
  let stackOpId = null;
126
132
  if (!currentStack) {
127
133
  stackOpId = 'op_stack_create';
@@ -258,6 +264,7 @@ async function buildPlan({ config, env, appName }) {
258
264
 
259
265
  if (Array.isArray(config.addons) && config.addons.length) {
260
266
  let addOnReadable = true;
267
+ let existingUserPools = null;
261
268
  for (const addOn of config.addons) {
262
269
  if (!addOn || typeof addOn !== 'object') continue;
263
270
  const apiType = mapAddOnType(addOn.type);
@@ -274,6 +281,28 @@ async function buildPlan({ config, env, appName }) {
274
281
 
275
282
  let currentAddOn = null;
276
283
  try {
284
+ if (apiType === 'COGNITO') {
285
+ try {
286
+ existingUserPools =
287
+ existingUserPools ||
288
+ (await api.listAddOnsByType({
289
+ accountId,
290
+ type: 'COGNITO',
291
+ take: 10
292
+ }));
293
+ } catch {
294
+ existingUserPools = null;
295
+ }
296
+ if (
297
+ Array.isArray(existingUserPools) &&
298
+ existingUserPools.length > 1
299
+ ) {
300
+ warnings.push(
301
+ `Multiple user groups add-ons exist in this account (${existingUserPools.length}). Scaly typically expects one user pool per account.`
302
+ );
303
+ }
304
+ }
305
+
277
306
  const list = await api.findAddOnByName({ name: addOn.name, accountId });
278
307
  currentAddOn = list?.[0] || null;
279
308
  if (list.length > 1) {
@@ -291,6 +320,44 @@ async function buildPlan({ config, env, appName }) {
291
320
  }
292
321
 
293
322
  if (!currentAddOn) {
323
+ if (apiType === 'COGNITO') {
324
+ const allowMultiple =
325
+ addOn.allow_multiple_user_pools === true ||
326
+ addOn.allowMultipleUserPools === true ||
327
+ config?.account?.allow_multiple_user_pools === true ||
328
+ config?.account?.allowMultipleUserPools === true;
329
+
330
+ const existing =
331
+ Array.isArray(existingUserPools) && existingUserPools.length
332
+ ? existingUserPools[0]
333
+ : null;
334
+
335
+ if (existing && !allowMultiple) {
336
+ warnings.push(
337
+ `User groups add-on '${addOn.name}' would create a new user pool, but this account already has '${existing.name}'. Reusing the existing pool is recommended to avoid "missing users" surprises. To create multiple pools, set addons[].allow_multiple_user_pools: true.`
338
+ );
339
+ if (requestedAuthPools.has(addOn.name)) {
340
+ warnings.push(
341
+ `app.auth.userPool is set to '${addOn.name}'. Consider changing it to '${existing.name}' to keep existing users.`
342
+ );
343
+ }
344
+ ops.push({
345
+ id: `op_addon_noop_${existing.name}`,
346
+ kind: 'addon',
347
+ action: 'noop',
348
+ resource: {
349
+ type: 'addon',
350
+ name: existing.name,
351
+ id: existing.id
352
+ },
353
+ current: pick(existing, ['id', 'name', 'type', 'accountId']),
354
+ desired: { name: existing.name, type: apiType, accountId },
355
+ diff: []
356
+ });
357
+ continue;
358
+ }
359
+ }
360
+
294
361
  const desired = { name: addOn.name, type: apiType, accountId };
295
362
  if (apiType === 'DATABASE') {
296
363
  const dbType = mapDbEngine(addOn.engine);
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@occam-scaly/scaly-cli",
3
- "version": "0.2.4",
3
+ "version": "0.2.6",
4
4
  "description": "Scaly CLI (auth + project config helpers)",
5
5
  "bin": {
6
6
  "scaly": "./bin/scaly.js"
@@ -10,6 +10,9 @@
10
10
  },
11
11
  "dependencies": {
12
12
  "axios": "^1.7.9",
13
+ "archiver": "^7.0.1",
14
+ "fast-glob": "^3.3.3",
15
+ "ignore": "^7.0.5",
13
16
  "ws": "^8.18.3",
14
17
  "yaml": "^2.8.1"
15
18
  },