@biznetgio/neo-app 0.4.6 → 0.4.7
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/package.json +1 -1
- package/src/commands/deploy.js +8 -0
- package/src/commands/doctor.js +3 -2
- package/src/creds.js +20 -5
- package/src/deploySource.js +187 -20
- package/src/index.js +7 -3
- package/src/mcp/tools.js +3 -0
package/package.json
CHANGED
package/src/commands/deploy.js
CHANGED
|
@@ -68,6 +68,9 @@ export async function deploy(source, opts = {}) {
|
|
|
68
68
|
const environment = cfg.env === 'staging' ? 'staging' : 'production';
|
|
69
69
|
log(`${c.bold('Deploying')} ${c.dim(`[${src.mode}] → ${cfg.env} · project ${projectId}`)}`);
|
|
70
70
|
|
|
71
|
+
const dryRun = !!opts['dry-run'];
|
|
72
|
+
if (dryRun) log(c.dim('dry-run — no changes will be made'));
|
|
73
|
+
|
|
71
74
|
const r = await deploySource(api, {
|
|
72
75
|
projectId,
|
|
73
76
|
src,
|
|
@@ -75,10 +78,15 @@ export async function deploy(source, opts = {}) {
|
|
|
75
78
|
port: opts.port,
|
|
76
79
|
build: opts.build,
|
|
77
80
|
environment,
|
|
81
|
+
dryRun,
|
|
78
82
|
timeout: Number(opts.timeout) || 300,
|
|
79
83
|
log: step,
|
|
80
84
|
});
|
|
81
85
|
|
|
86
|
+
if (r.dryRun) {
|
|
87
|
+
ok(`dry-run complete — ${r.plan.length} step(s) planned, nothing written`);
|
|
88
|
+
return r;
|
|
89
|
+
}
|
|
82
90
|
if (r.pending) {
|
|
83
91
|
ok(`submitted — pending approval (${r.approvalId}). An owner/admin must approve.`);
|
|
84
92
|
return;
|
package/src/commands/doctor.js
CHANGED
|
@@ -74,8 +74,9 @@ export async function doctor(opts = {}) {
|
|
|
74
74
|
const info = storeInfo();
|
|
75
75
|
if (cred?.source === 'env') ok('credentials from NEO_APP_TOKEN (env)');
|
|
76
76
|
else if (!info.exists) warn('no credential file');
|
|
77
|
-
else if (info.secure) ok(`credentials file
|
|
78
|
-
else bad(`credentials file is
|
|
77
|
+
else if (info.secure === true) ok(`credentials file access-restricted (${info.path})`);
|
|
78
|
+
else if (info.secure === false) bad(`credentials file is world-accessible${info.mode ? ` (mode ${info.mode})` : ''} — run '${info.remediation}'`);
|
|
79
|
+
else warn(`could not verify credentials file permissions (${info.path})`);
|
|
79
80
|
|
|
80
81
|
// Required executables
|
|
81
82
|
onPath('npx') ? ok('npx available') : warn("npx not found — needed for the npx install path");
|
package/src/creds.js
CHANGED
|
@@ -11,6 +11,7 @@
|
|
|
11
11
|
import { homedir } from 'node:os';
|
|
12
12
|
import { join } from 'node:path';
|
|
13
13
|
import { mkdirSync, readFileSync, writeFileSync, chmodSync, existsSync, rmSync, statSync } from 'node:fs';
|
|
14
|
+
import { execFileSync } from 'node:child_process';
|
|
14
15
|
|
|
15
16
|
function storeDir() {
|
|
16
17
|
const base = process.env.XDG_CONFIG_HOME || join(homedir(), '.config');
|
|
@@ -69,17 +70,31 @@ export function deleteCredential(baseUrl) {
|
|
|
69
70
|
}
|
|
70
71
|
|
|
71
72
|
// storeInfo reports where creds live and whether the file is user-only (for doctor).
|
|
73
|
+
// Security is judged per-platform (JNG #98): POSIX mode bits on Linux/macOS, the NTFS
|
|
74
|
+
// ACL on Windows — where stat.mode is meaningless (it reports 0o666 even for a locked
|
|
75
|
+
// file), so `chmod 600` is never used as the proof. `remediation` is the platform's
|
|
76
|
+
// own fix command. On any error `secure` stays null (unknown) rather than falsely
|
|
77
|
+
// flagged. No credential value is read or returned.
|
|
72
78
|
export function storeInfo() {
|
|
73
79
|
const path = storePath();
|
|
74
|
-
const info = { path, exists: existsSync(path), secure: null };
|
|
75
|
-
if (info.exists)
|
|
76
|
-
|
|
80
|
+
const info = { path, platform: process.platform, exists: existsSync(path), secure: null };
|
|
81
|
+
if (!info.exists) return info;
|
|
82
|
+
try {
|
|
83
|
+
if (process.platform === 'win32') {
|
|
84
|
+
// Secure when the ACL grants no access to broad principals (Everyone / Users /
|
|
85
|
+
// Authenticated Users). icacls prints one grant per line as `principal:(perms)`.
|
|
86
|
+
const acl = execFileSync('icacls', [path], { encoding: 'utf8', stdio: ['ignore', 'pipe', 'ignore'] });
|
|
87
|
+
const broad = /(^|\\|\s)(Everyone|Users|Authenticated Users|Tout le monde)\s*:/im.test(acl);
|
|
88
|
+
info.secure = !broad;
|
|
89
|
+
info.remediation = `icacls "${path}" /inheritance:r /grant:r "%USERNAME%":F`;
|
|
90
|
+
} else {
|
|
77
91
|
const mode = statSync(path).mode & 0o777;
|
|
78
92
|
info.secure = mode === 0o600;
|
|
79
93
|
info.mode = mode.toString(8);
|
|
80
|
-
|
|
81
|
-
/* ignore */
|
|
94
|
+
info.remediation = `chmod 600 "${path}"`;
|
|
82
95
|
}
|
|
96
|
+
} catch {
|
|
97
|
+
/* secure stays null (couldn't determine) */
|
|
83
98
|
}
|
|
84
99
|
return info;
|
|
85
100
|
}
|
package/src/deploySource.js
CHANGED
|
@@ -3,7 +3,7 @@
|
|
|
3
3
|
// .zip → as-is) when relevant, trigger the deploy, and poll to a terminal state.
|
|
4
4
|
|
|
5
5
|
import { basename, join } from 'node:path';
|
|
6
|
-
import { existsSync, readFileSync, rmSync } from 'node:fs';
|
|
6
|
+
import { existsSync, readFileSync, readdirSync, writeFileSync, rmSync } from 'node:fs';
|
|
7
7
|
import { execFileSync } from 'node:child_process';
|
|
8
8
|
import { tmpdir } from 'node:os';
|
|
9
9
|
|
|
@@ -17,44 +17,201 @@ function pending(res) {
|
|
|
17
17
|
return res.status === 202 && res.data && res.data.status === 'pending_approval';
|
|
18
18
|
}
|
|
19
19
|
|
|
20
|
+
// --- source safety (JNG #95) --------------------------------------------------
|
|
21
|
+
// Files that must never be uploaded in a build context, whatever the ignore files
|
|
22
|
+
// say. `.env.example`/`.sample`/`.template` are shareable templates, not secrets.
|
|
23
|
+
function isSecretPath(rel) {
|
|
24
|
+
const base = rel.replace(/^\.\//, '').split('/').pop();
|
|
25
|
+
if (base === '.env' || /^\.env\.(?!example$|sample$|template$)[^/]*$/i.test(base)) return true;
|
|
26
|
+
if (/\.(pem|key|p12|pfx|keystore|jks)$/i.test(base)) return true;
|
|
27
|
+
if (/^id_(rsa|dsa|ecdsa|ed25519)$/.test(base)) return true;
|
|
28
|
+
return false;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
// Exclude patterns: always skip .git/node_modules, then honor .gitignore and
|
|
32
|
+
// .neoappignore (comments, blanks and negations are skipped — a conservative packer).
|
|
33
|
+
function collectExcludes(dir) {
|
|
34
|
+
const excludes = new Set(['.git', 'node_modules']);
|
|
35
|
+
for (const f of ['.gitignore', '.neoappignore']) {
|
|
36
|
+
const p = join(dir, f);
|
|
37
|
+
if (!existsSync(p)) continue;
|
|
38
|
+
for (let line of readFileSync(p, 'utf8').split(/\r?\n/)) {
|
|
39
|
+
line = line.trim();
|
|
40
|
+
if (!line || line.startsWith('#') || line.startsWith('!')) continue;
|
|
41
|
+
excludes.add(line.replace(/^\/+/, '').replace(/\/+$/, ''));
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
return excludes;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
// A relative path is excluded if a pattern matches its basename, the whole path, any
|
|
48
|
+
// path segment (a directory like node_modules), or a simple `*.ext` glob — a small,
|
|
49
|
+
// gitignore-ish matcher good enough for the ignore files we support.
|
|
50
|
+
function matchesExclude(rel, excludes) {
|
|
51
|
+
const parts = rel.split('/');
|
|
52
|
+
const base = parts[parts.length - 1];
|
|
53
|
+
for (const pat of excludes) {
|
|
54
|
+
if (pat === base || pat === rel || parts.includes(pat)) return true;
|
|
55
|
+
if (pat.startsWith('*.') && base.endsWith(pat.slice(1))) return true;
|
|
56
|
+
}
|
|
57
|
+
return false;
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
// Build the explicit list of files to pack (relative to dir), honoring the ignore
|
|
61
|
+
// files and never including secrets. We tar this exact list (tar -T) instead of
|
|
62
|
+
// relying on `tar --exclude`, whose glob semantics differ across tar implementations
|
|
63
|
+
// (GNU vs bsd vs busybox) and silently let ignored/secret files through (JNG #95).
|
|
64
|
+
function collectIncludedFiles(dir) {
|
|
65
|
+
const excludes = collectExcludes(dir);
|
|
66
|
+
const out = [];
|
|
67
|
+
const walk = (d, rel) => {
|
|
68
|
+
let entries;
|
|
69
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
70
|
+
for (const e of entries) {
|
|
71
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
72
|
+
if (matchesExclude(r, excludes) || isSecretPath(r)) continue;
|
|
73
|
+
if (e.isDirectory()) walk(join(d, e.name), r);
|
|
74
|
+
else out.push(r);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
walk(dir, '');
|
|
78
|
+
return out;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Secret files found under dir (for the heads-up log and the .zip check).
|
|
82
|
+
function findSecretFiles(dir) {
|
|
83
|
+
const out = [];
|
|
84
|
+
const walk = (d, rel) => {
|
|
85
|
+
let entries;
|
|
86
|
+
try { entries = readdirSync(d, { withFileTypes: true }); } catch { return; }
|
|
87
|
+
for (const e of entries) {
|
|
88
|
+
if (e.name === '.git' || e.name === 'node_modules') continue;
|
|
89
|
+
const r = rel ? `${rel}/${e.name}` : e.name;
|
|
90
|
+
if (e.isDirectory()) walk(join(d, e.name), r);
|
|
91
|
+
else if (isSecretPath(r)) out.push(r);
|
|
92
|
+
}
|
|
93
|
+
};
|
|
94
|
+
walk(dir, '');
|
|
95
|
+
return out;
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
// Read a .zip's entry names from its central directory (no unzip binary needed) so we
|
|
99
|
+
// can audit a prebuilt archive for secret files before uploading it (JNG #95).
|
|
100
|
+
function zipEntryNames(buf) {
|
|
101
|
+
const names = [];
|
|
102
|
+
let eocd = -1;
|
|
103
|
+
for (let i = buf.length - 22; i >= Math.max(0, buf.length - 22 - 0xffff); i--) {
|
|
104
|
+
if (buf.readUInt32LE(i) === 0x06054b50) { eocd = i; break; }
|
|
105
|
+
}
|
|
106
|
+
if (eocd < 0) return null; // not a parseable zip — caller decides how strict to be
|
|
107
|
+
let off = buf.readUInt32LE(eocd + 16);
|
|
108
|
+
const count = buf.readUInt16LE(eocd + 10);
|
|
109
|
+
for (let i = 0; i < count && off + 46 <= buf.length; i++) {
|
|
110
|
+
if (buf.readUInt32LE(off) !== 0x02014b50) break;
|
|
111
|
+
const nameLen = buf.readUInt16LE(off + 28);
|
|
112
|
+
const extraLen = buf.readUInt16LE(off + 30);
|
|
113
|
+
const commentLen = buf.readUInt16LE(off + 32);
|
|
114
|
+
names.push(buf.toString('utf8', off + 46, off + 46 + nameLen));
|
|
115
|
+
off += 46 + nameLen + extraLen + commentLen;
|
|
116
|
+
}
|
|
117
|
+
return names;
|
|
118
|
+
}
|
|
119
|
+
|
|
20
120
|
// deploySource ships a resolved source to a project. `src` is one of:
|
|
21
121
|
// { mode:'image', image } { mode:'git', repo, branch? }
|
|
22
122
|
// { mode:'dir', dir } { mode:'zip', zipFile }
|
|
123
|
+
// With `dryRun`, it performs NO create/update/upload/deploy — only the GETs needed to
|
|
124
|
+
// build a plan — and returns { dryRun:true, plan } (JNG #93).
|
|
23
125
|
// Returns { pending, approvalId } or { appId, url, result }. `log(msg)` is optional.
|
|
24
|
-
export async function deploySource(api, { projectId, src, name, port, build, environment, timeout = 300, log = () => {} }) {
|
|
126
|
+
export async function deploySource(api, { projectId, src, name, port, build, environment, dryRun = false, timeout = 300, log = () => {} }) {
|
|
25
127
|
const defaultName =
|
|
26
128
|
src.mode === 'image' ? src.image.split('/').pop().split(':')[0]
|
|
27
129
|
: src.mode === 'git' ? basename(src.repo, '.git')
|
|
28
130
|
: src.mode === 'zip' ? basename(src.zipFile, '.zip')
|
|
29
131
|
: basename(src.dir);
|
|
30
132
|
const svc = sanitizeName(name || defaultName);
|
|
133
|
+
const targetEnv = environment || 'production';
|
|
31
134
|
|
|
32
135
|
let buildType = 'railpack';
|
|
33
136
|
if (src.mode === 'image') buildType = 'image';
|
|
34
137
|
else if (build) buildType = build;
|
|
35
138
|
else if (src.mode === 'dir' && existsSync(join(src.dir, 'Dockerfile'))) buildType = 'dockerfile';
|
|
36
139
|
|
|
140
|
+
// Select an existing service by name AND environment — a Staging deploy must never
|
|
141
|
+
// reuse/mutate a Production service of the same name, or vice versa (JNG #94). The
|
|
142
|
+
// result is order-independent (filter, not first-match).
|
|
37
143
|
const existing = (await api.get(`/api/v1/projects/${projectId}/apps`)).data?.applications || [];
|
|
38
|
-
|
|
144
|
+
const sameName = existing.filter((a) => a.name === svc);
|
|
145
|
+
let app = sameName.find((a) => (a.environment || 'production') === targetEnv) || null;
|
|
146
|
+
if (!app && sameName.length) {
|
|
147
|
+
const envs = [...new Set(sameName.map((a) => a.environment || 'production'))].sort().join(', ');
|
|
148
|
+
throw new Error(
|
|
149
|
+
`a service named "${svc}" already exists in ${envs}, not in ${targetEnv} — ` +
|
|
150
|
+
`choose a different --name or target that environment`,
|
|
151
|
+
);
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Source-safety preflight — BEFORE any mutation (and in dry-run too, it's read-only)
|
|
155
|
+
// so an unsafe source aborts before a service is even created (JNG #95). Secrets are
|
|
156
|
+
// named, never their contents.
|
|
157
|
+
let dirSecrets = [];
|
|
158
|
+
if (src.mode === 'dir') {
|
|
159
|
+
dirSecrets = findSecretFiles(src.dir);
|
|
160
|
+
if (dirSecrets.length) log(`excluding secret file(s) from the build context: ${dirSecrets.join(', ')}`);
|
|
161
|
+
} else if (src.mode === 'zip') {
|
|
162
|
+
const names = zipEntryNames(readFileSync(src.zipFile));
|
|
163
|
+
const zipSecrets = (names || []).filter(isSecretPath);
|
|
164
|
+
if (zipSecrets.length) {
|
|
165
|
+
throw new Error(
|
|
166
|
+
`refusing to upload ${basename(src.zipFile)}: it contains secret file(s) ${zipSecrets.join(', ')} — ` +
|
|
167
|
+
`remove them from the archive before deploying`,
|
|
168
|
+
);
|
|
169
|
+
}
|
|
170
|
+
}
|
|
39
171
|
|
|
172
|
+
const plan = [];
|
|
40
173
|
if (!app) {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
174
|
+
plan.push(`create web service "${svc}" (${buildType}) in ${targetEnv}`);
|
|
175
|
+
if (!dryRun) {
|
|
176
|
+
const res = await api.post(`/api/v1/projects/${projectId}/apps`, {
|
|
177
|
+
name: svc,
|
|
178
|
+
environment: targetEnv,
|
|
179
|
+
serviceType: 'web',
|
|
180
|
+
buildType,
|
|
181
|
+
image: src.image || undefined,
|
|
182
|
+
repoUrl: src.repo || undefined,
|
|
183
|
+
branch: src.branch || undefined,
|
|
184
|
+
port: port ? Number(port) : undefined,
|
|
185
|
+
tls: true,
|
|
186
|
+
});
|
|
187
|
+
if (pending(res)) return { pending: true, approvalId: res.data.approvalId };
|
|
188
|
+
app = res.data?.application || res.data;
|
|
189
|
+
log(`created service ${svc}`);
|
|
190
|
+
}
|
|
55
191
|
} else {
|
|
56
|
-
|
|
57
|
-
|
|
192
|
+
// Existing service: apply requested source changes (image/repo/branch/build) BEFORE
|
|
193
|
+
// deploying, so a redeploy never silently ships the old source config (JNG #96).
|
|
194
|
+
const patch = {};
|
|
195
|
+
if (src.mode === 'image' && src.image && app.image !== src.image) patch.image = src.image;
|
|
196
|
+
if (src.mode === 'git') {
|
|
197
|
+
if (src.repo && app.repoUrl !== src.repo) patch.repoUrl = src.repo;
|
|
198
|
+
if (src.branch && app.branch !== src.branch) patch.branch = src.branch;
|
|
199
|
+
}
|
|
200
|
+
if (buildType && app.buildType && app.buildType !== buildType) patch.buildType = buildType;
|
|
201
|
+
if (Object.keys(patch).length) {
|
|
202
|
+
plan.push(`update "${svc}": ${Object.keys(patch).join(', ')}`);
|
|
203
|
+
if (!dryRun) { await api.put(`/api/v1/apps/${app.id}`, patch); log(`updated source config (${Object.keys(patch).join(', ')})`); }
|
|
204
|
+
} else {
|
|
205
|
+
log(`service ${svc} exists — redeploying`);
|
|
206
|
+
}
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
if (src.mode === 'dir' || src.mode === 'zip') plan.push(`upload build context (${src.mode})`);
|
|
210
|
+
plan.push(`deploy "${svc}"`);
|
|
211
|
+
|
|
212
|
+
if (dryRun) {
|
|
213
|
+
for (const p of plan) log(`[dry-run] would ${p}`);
|
|
214
|
+
return { dryRun: true, plan, service: svc, environment: targetEnv };
|
|
58
215
|
}
|
|
59
216
|
|
|
60
217
|
if (src.mode === 'dir' || src.mode === 'zip') {
|
|
@@ -69,7 +226,17 @@ export async function deploySource(api, { projectId, src, name, port, build, env
|
|
|
69
226
|
archive = join(tmpdir(), `neo-deploy-${app.id}-${Date.now()}.tgz`);
|
|
70
227
|
packed = true;
|
|
71
228
|
log('packing the directory…');
|
|
72
|
-
|
|
229
|
+
// Pack exactly the allowed files (ignore-respecting, secret-free) via a list,
|
|
230
|
+
// so no ignored/secret file can slip through a tar --exclude quirk (JNG #95).
|
|
231
|
+
const files = collectIncludedFiles(src.dir);
|
|
232
|
+
if (!files.length) throw new Error('nothing to deploy — the folder is empty after applying ignore rules');
|
|
233
|
+
const listFile = join(tmpdir(), `neo-files-${app.id}-${Date.now()}.txt`);
|
|
234
|
+
writeFileSync(listFile, files.join('\n') + '\n');
|
|
235
|
+
try {
|
|
236
|
+
execFileSync('tar', ['czf', archive, '-C', src.dir, '-T', listFile], { stdio: 'ignore' });
|
|
237
|
+
} finally {
|
|
238
|
+
rmSync(listFile, { force: true });
|
|
239
|
+
}
|
|
73
240
|
buf = readFileSync(archive);
|
|
74
241
|
ctype = 'application/gzip';
|
|
75
242
|
}
|
package/src/index.js
CHANGED
|
@@ -85,12 +85,16 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
85
85
|
const { values: opts, positionals } = parsed;
|
|
86
86
|
if (opts.dev) opts.staging = true;
|
|
87
87
|
|
|
88
|
-
if (opts.version && positionals.length === 0) return void console.log(version());
|
|
89
|
-
if (opts.help || positionals.length === 0) return void console.log(HELP);
|
|
90
|
-
|
|
91
88
|
const cmd = positionals[0];
|
|
92
89
|
const sub = positionals[1];
|
|
93
90
|
|
|
91
|
+
if (opts.version && positionals.length === 0) return void console.log(version());
|
|
92
|
+
// Root help, and `--help` on a KNOWN command, print usage. An UNKNOWN command must
|
|
93
|
+
// still error (nonzero exit) even with `--help`, so a bad command isn't silently
|
|
94
|
+
// accepted (JNG #97). Per-subcommand help is tracked in #89.
|
|
95
|
+
const KNOWN_COMMANDS = new Set(['agent', 'setup', 'config', 'login', 'deploy', 'link', 'project', 'status', 'whoami', 'mcp', 'version', 'help']);
|
|
96
|
+
if (positionals.length === 0 || (opts.help && KNOWN_COMMANDS.has(cmd))) return void console.log(HELP);
|
|
97
|
+
|
|
94
98
|
try {
|
|
95
99
|
// `agent setup` and `setup agent` both work (parity with InstaCloud's `setup agent`).
|
|
96
100
|
if ((cmd === 'agent' && sub === 'setup') || (cmd === 'setup' && sub === 'agent')) return void (await (await import('./commands/setup.js')).setup(opts));
|
package/src/mcp/tools.js
CHANGED
|
@@ -226,6 +226,7 @@ export const TOOLS = [
|
|
|
226
226
|
name: str('Service name (optional)'),
|
|
227
227
|
port: int('Container port (optional)'),
|
|
228
228
|
environment: str('production or staging (optional, default production)'),
|
|
229
|
+
dryRun: { type: 'boolean', description: 'Preview the plan without creating/uploading/deploying anything (JNG #93)' },
|
|
229
230
|
},
|
|
230
231
|
required: ['projectId', 'path'],
|
|
231
232
|
},
|
|
@@ -246,7 +247,9 @@ export const TOOLS = [
|
|
|
246
247
|
name: args.name,
|
|
247
248
|
port: args.port,
|
|
248
249
|
environment: args.environment || 'production',
|
|
250
|
+
dryRun: !!args.dryRun,
|
|
249
251
|
});
|
|
252
|
+
if (r.dryRun) return { status: 'dry_run', plan: r.plan, service: r.service, environment: r.environment };
|
|
250
253
|
if (r.pending) return { status: 'pending_approval', approvalId: r.approvalId };
|
|
251
254
|
return { status: r.result, url: r.url || undefined, appId: r.appId };
|
|
252
255
|
},
|