@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 +143 -2
- package/lib/scaly-api.js +31 -0
- package/lib/scaly-artifacts.js +295 -0
- package/lib/scaly-deploy.js +14 -0
- package/lib/scaly-plan.js +67 -0
- package/package.json +4 -1
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
|
+
};
|
package/lib/scaly-deploy.js
CHANGED
|
@@ -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.
|
|
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
|
},
|