@quenty/nevermore-cli 4.37.0 → 4.38.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +6 -0
- package/dist/commands/deploy-command/index.d.ts.map +1 -1
- package/dist/commands/deploy-command/index.js +43 -0
- package/dist/commands/deploy-command/index.js.map +1 -1
- package/dist/commands/deploy-command/version-command.d.ts +21 -0
- package/dist/commands/deploy-command/version-command.d.ts.map +1 -0
- package/dist/commands/deploy-command/version-command.js +206 -0
- package/dist/commands/deploy-command/version-command.js.map +1 -0
- package/dist/utils/build/deploy-config.d.ts +8 -0
- package/dist/utils/build/deploy-config.d.ts.map +1 -1
- package/dist/utils/build/deploy-config.js +5 -0
- package/dist/utils/build/deploy-config.js.map +1 -1
- package/dist/utils/build/deploy-config.test.js +86 -1
- package/dist/utils/build/deploy-config.test.js.map +1 -1
- package/dist/utils/job-context/base-job-context.d.ts.map +1 -1
- package/dist/utils/job-context/base-job-context.js +4 -2
- package/dist/utils/job-context/base-job-context.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts +12 -1
- package/dist/utils/open-cloud/open-cloud-client.d.ts.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.js +49 -4
- package/dist/utils/open-cloud/open-cloud-client.js.map +1 -1
- package/dist/utils/open-cloud/open-cloud-client.test.js +89 -0
- package/dist/utils/open-cloud/open-cloud-client.test.js.map +1 -1
- package/package.json +2 -2
- package/src/commands/deploy-command/index.ts +71 -0
- package/src/commands/deploy-command/version-command.ts +311 -0
- package/src/utils/build/deploy-config.test.ts +98 -0
- package/src/utils/build/deploy-config.ts +17 -0
- package/src/utils/job-context/base-job-context.ts +7 -2
- package/src/utils/open-cloud/open-cloud-client.test.ts +132 -0
- package/src/utils/open-cloud/open-cloud-client.ts +84 -5
- package/tsconfig.tsbuildinfo +1 -1
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import inquirer from 'inquirer';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import { OutputHelper } from '@quenty/cli-output-helpers';
|
|
4
|
+
import {
|
|
5
|
+
formatTable,
|
|
6
|
+
type TableColumn,
|
|
7
|
+
} from '@quenty/cli-output-helpers/reporting';
|
|
8
|
+
import { getApiKeyAsync } from '@quenty/nevermore-cli-helpers';
|
|
9
|
+
import {
|
|
10
|
+
loadDeployConfigAsync,
|
|
11
|
+
resolveDeployConfigPath,
|
|
12
|
+
resolveDeployTargetPlaces,
|
|
13
|
+
type BasePlaceConfig,
|
|
14
|
+
type DeployConfig,
|
|
15
|
+
} from '../../utils/build/deploy-config.js';
|
|
16
|
+
import { OpenCloudClient } from '../../utils/open-cloud/open-cloud-client.js';
|
|
17
|
+
import { RateLimiter } from '../../utils/open-cloud/rate-limiter.js';
|
|
18
|
+
import { DeployArgs } from './index.js';
|
|
19
|
+
|
|
20
|
+
interface BasePlaceRef {
|
|
21
|
+
targetName: string;
|
|
22
|
+
placeLabel: string;
|
|
23
|
+
basePlace: BasePlaceConfig;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** A pending change: set `ref.basePlace.version` to `to`. */
|
|
27
|
+
interface PinChange {
|
|
28
|
+
ref: BasePlaceRef;
|
|
29
|
+
from?: number;
|
|
30
|
+
to: number;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export interface VersionPromoteArgs extends DeployArgs {
|
|
34
|
+
from?: string;
|
|
35
|
+
to?: string;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
function _plural(count: number): string {
|
|
39
|
+
return count === 1 ? '' : 's';
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function _requireTarget(config: DeployConfig, targetName: string): void {
|
|
43
|
+
if (!config.targets[targetName]) {
|
|
44
|
+
throw new Error(
|
|
45
|
+
[
|
|
46
|
+
`Target "${targetName}" not found in deploy.nevermore.json.`,
|
|
47
|
+
`Available targets: ${Object.keys(config.targets).join(', ')}`,
|
|
48
|
+
].join('\n')
|
|
49
|
+
);
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** Collect every basePlace across the given targets, keeping live references. */
|
|
54
|
+
function _collectBasePlaceRefs(
|
|
55
|
+
config: DeployConfig,
|
|
56
|
+
targetNames: string[]
|
|
57
|
+
): BasePlaceRef[] {
|
|
58
|
+
const refs: BasePlaceRef[] = [];
|
|
59
|
+
for (const targetName of targetNames) {
|
|
60
|
+
for (const place of resolveDeployTargetPlaces(config, targetName)) {
|
|
61
|
+
if (place.basePlace) {
|
|
62
|
+
refs.push({
|
|
63
|
+
targetName,
|
|
64
|
+
placeLabel: place.name ?? targetName,
|
|
65
|
+
basePlace: place.basePlace,
|
|
66
|
+
});
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
return refs;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
/**
|
|
74
|
+
* Gate the change set on --dryrun / confirmation, apply it in place, and write
|
|
75
|
+
* the config back. `changes` may include no-op entries; only entries whose
|
|
76
|
+
* version actually differs are counted and applied. Returns nothing — messaging
|
|
77
|
+
* is handled here so the two commands stay consistent.
|
|
78
|
+
*/
|
|
79
|
+
async function _commitPinChangesAsync(
|
|
80
|
+
configPath: string,
|
|
81
|
+
config: DeployConfig,
|
|
82
|
+
changes: PinChange[],
|
|
83
|
+
args: { dryrun?: boolean; yes?: boolean }
|
|
84
|
+
): Promise<void> {
|
|
85
|
+
const changed = changes.filter((c) => c.from !== c.to);
|
|
86
|
+
if (changed.length === 0) {
|
|
87
|
+
OutputHelper.info('Nothing to change — pins are already up to date.');
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
if (args.dryrun) {
|
|
92
|
+
OutputHelper.info(
|
|
93
|
+
`[DRYRUN] Would update ${changed.length} pin${_plural(
|
|
94
|
+
changed.length
|
|
95
|
+
)} in ${configPath}`
|
|
96
|
+
);
|
|
97
|
+
return;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!args.yes) {
|
|
101
|
+
const { confirm } = await inquirer.prompt([
|
|
102
|
+
{
|
|
103
|
+
type: 'confirm',
|
|
104
|
+
name: 'confirm',
|
|
105
|
+
message: `Update ${changed.length} version pin${_plural(
|
|
106
|
+
changed.length
|
|
107
|
+
)} in deploy.nevermore.json?`,
|
|
108
|
+
default: true,
|
|
109
|
+
},
|
|
110
|
+
]);
|
|
111
|
+
if (!confirm) {
|
|
112
|
+
OutputHelper.info('Aborted — no changes written.');
|
|
113
|
+
return;
|
|
114
|
+
}
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
// Mutate in place — every ref.basePlace points into `config`.
|
|
118
|
+
for (const change of changed) {
|
|
119
|
+
change.ref.basePlace.version = change.to;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
await fs.writeFile(configPath, JSON.stringify(config, null, 2) + '\n');
|
|
123
|
+
OutputHelper.info(
|
|
124
|
+
`Updated ${changed.length} version pin${_plural(
|
|
125
|
+
changed.length
|
|
126
|
+
)} in ${configPath}`
|
|
127
|
+
);
|
|
128
|
+
OutputHelper.hint(
|
|
129
|
+
'Commit deploy.nevermore.json, then deploy to roll base places forward.'
|
|
130
|
+
);
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/**
|
|
134
|
+
* `nevermore deploy version upgrade [target]` — re-pin every `basePlace` in
|
|
135
|
+
* deploy.nevermore.json to its current latest published version. Without a
|
|
136
|
+
* target it walks every target; pass one to scope it. `--dryrun` previews the
|
|
137
|
+
* change set without writing.
|
|
138
|
+
*/
|
|
139
|
+
export async function handleVersionUpgradeAsync(
|
|
140
|
+
args: DeployArgs
|
|
141
|
+
): Promise<void> {
|
|
142
|
+
const cwd = process.cwd();
|
|
143
|
+
const configPath = resolveDeployConfigPath(cwd);
|
|
144
|
+
const config = await loadDeployConfigAsync(configPath);
|
|
145
|
+
|
|
146
|
+
if (args.target) {
|
|
147
|
+
_requireTarget(config, args.target);
|
|
148
|
+
}
|
|
149
|
+
|
|
150
|
+
const targetNames = args.target ? [args.target] : Object.keys(config.targets);
|
|
151
|
+
const refs = _collectBasePlaceRefs(config, targetNames);
|
|
152
|
+
|
|
153
|
+
if (refs.length === 0) {
|
|
154
|
+
OutputHelper.warn(
|
|
155
|
+
args.target
|
|
156
|
+
? `Target "${args.target}" has no basePlace to pin.`
|
|
157
|
+
: 'No basePlace found in deploy.nevermore.json — nothing to pin.'
|
|
158
|
+
);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
// Resolve the latest version once per unique base place id — several targets
|
|
163
|
+
// (e.g. integration/prod) commonly point at the same base place.
|
|
164
|
+
const uniquePlaceIds = [...new Set(refs.map((r) => r.basePlace.placeId))];
|
|
165
|
+
OutputHelper.info(
|
|
166
|
+
`Resolving latest version for ${uniquePlaceIds.length} base place${_plural(
|
|
167
|
+
uniquePlaceIds.length
|
|
168
|
+
)}...`
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
const apiKey = await getApiKeyAsync(args);
|
|
172
|
+
const client = new OpenCloudClient({
|
|
173
|
+
apiKey,
|
|
174
|
+
rateLimiter: new RateLimiter(),
|
|
175
|
+
});
|
|
176
|
+
|
|
177
|
+
const latestByPlaceId = new Map<number, number>();
|
|
178
|
+
await Promise.all(
|
|
179
|
+
uniquePlaceIds.map(async (placeId) => {
|
|
180
|
+
const ref = refs.find((r) => r.basePlace.placeId === placeId)!;
|
|
181
|
+
const latest = await client.getLatestPlaceVersionAsync(
|
|
182
|
+
ref.basePlace.universeId,
|
|
183
|
+
placeId
|
|
184
|
+
);
|
|
185
|
+
latestByPlaceId.set(placeId, latest);
|
|
186
|
+
})
|
|
187
|
+
);
|
|
188
|
+
|
|
189
|
+
const changes: PinChange[] = refs.map((ref) => ({
|
|
190
|
+
ref,
|
|
191
|
+
from: ref.basePlace.version,
|
|
192
|
+
to: latestByPlaceId.get(ref.basePlace.placeId)!,
|
|
193
|
+
}));
|
|
194
|
+
|
|
195
|
+
const columns: TableColumn<PinChange>[] = [
|
|
196
|
+
{ header: 'Target', value: (c) => c.ref.targetName },
|
|
197
|
+
{ header: 'Place', value: (c) => c.ref.placeLabel },
|
|
198
|
+
{ header: 'Base place', value: (c) => String(c.ref.basePlace.placeId) },
|
|
199
|
+
{ header: 'Version', value: (c) => _formatChange(c) },
|
|
200
|
+
];
|
|
201
|
+
|
|
202
|
+
console.log('');
|
|
203
|
+
console.log(formatTable(changes, columns, { indent: ' ' }));
|
|
204
|
+
console.log('');
|
|
205
|
+
|
|
206
|
+
await _commitPinChangesAsync(configPath, config, changes, args);
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/**
|
|
210
|
+
* `nevermore deploy version promote <from> <to>` — copy the base-place version
|
|
211
|
+
* pins from one target to another (e.g. promote validated `production-demo`
|
|
212
|
+
* pins to `production`). Places are matched by their base place id, so the same
|
|
213
|
+
* source content lines up even when the two targets name their places
|
|
214
|
+
* differently. Pure config edit — no network. `--dryrun` previews.
|
|
215
|
+
*/
|
|
216
|
+
export async function handleVersionPromoteAsync(
|
|
217
|
+
args: VersionPromoteArgs
|
|
218
|
+
): Promise<void> {
|
|
219
|
+
const fromTarget = args.from;
|
|
220
|
+
const toTarget = args.to;
|
|
221
|
+
if (!fromTarget || !toTarget) {
|
|
222
|
+
throw new Error(
|
|
223
|
+
'Usage: nevermore deploy version copy <from-target> <to-target>'
|
|
224
|
+
);
|
|
225
|
+
}
|
|
226
|
+
if (fromTarget === toTarget) {
|
|
227
|
+
throw new Error('The <from> and <to> targets must be different.');
|
|
228
|
+
}
|
|
229
|
+
|
|
230
|
+
const cwd = process.cwd();
|
|
231
|
+
const configPath = resolveDeployConfigPath(cwd);
|
|
232
|
+
const config = await loadDeployConfigAsync(configPath);
|
|
233
|
+
|
|
234
|
+
_requireTarget(config, fromTarget);
|
|
235
|
+
_requireTarget(config, toTarget);
|
|
236
|
+
|
|
237
|
+
// Map base place id -> pinned version from the source target. A source with
|
|
238
|
+
// the same base place pinned to two different versions is inconsistent, so
|
|
239
|
+
// fail loudly rather than pick one arbitrarily.
|
|
240
|
+
const versionByPlaceId = new Map<number, number>();
|
|
241
|
+
for (const ref of _collectBasePlaceRefs(config, [fromTarget])) {
|
|
242
|
+
const version = ref.basePlace.version;
|
|
243
|
+
if (version == null) {
|
|
244
|
+
continue;
|
|
245
|
+
}
|
|
246
|
+
const existing = versionByPlaceId.get(ref.basePlace.placeId);
|
|
247
|
+
if (existing != null && existing !== version) {
|
|
248
|
+
throw new Error(
|
|
249
|
+
`Target "${fromTarget}" pins base place ${ref.basePlace.placeId} to ` +
|
|
250
|
+
`two different versions (v${existing} and v${version}). ` +
|
|
251
|
+
`Run "nevermore deploy version upgrade ${fromTarget}" to make it consistent first.`
|
|
252
|
+
);
|
|
253
|
+
}
|
|
254
|
+
versionByPlaceId.set(ref.basePlace.placeId, version);
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (versionByPlaceId.size === 0) {
|
|
258
|
+
OutputHelper.warn(
|
|
259
|
+
`Target "${fromTarget}" has no pinned base place versions to copy. ` +
|
|
260
|
+
`Run "nevermore deploy version upgrade ${fromTarget}" first.`
|
|
261
|
+
);
|
|
262
|
+
return;
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
const toRefs = _collectBasePlaceRefs(config, [toTarget]);
|
|
266
|
+
if (toRefs.length === 0) {
|
|
267
|
+
OutputHelper.warn(`Target "${toTarget}" has no basePlace to copy pins to.`);
|
|
268
|
+
return;
|
|
269
|
+
}
|
|
270
|
+
|
|
271
|
+
const changes: PinChange[] = [];
|
|
272
|
+
const unmatched: BasePlaceRef[] = [];
|
|
273
|
+
for (const ref of toRefs) {
|
|
274
|
+
const source = versionByPlaceId.get(ref.basePlace.placeId);
|
|
275
|
+
if (source == null) {
|
|
276
|
+
unmatched.push(ref);
|
|
277
|
+
continue;
|
|
278
|
+
}
|
|
279
|
+
changes.push({ ref, from: ref.basePlace.version, to: source });
|
|
280
|
+
}
|
|
281
|
+
|
|
282
|
+
const columns: TableColumn<PinChange>[] = [
|
|
283
|
+
{ header: 'Place', value: (c) => c.ref.placeLabel },
|
|
284
|
+
{ header: 'Base place', value: (c) => String(c.ref.basePlace.placeId) },
|
|
285
|
+
{ header: 'Version', value: (c) => _formatChange(c) },
|
|
286
|
+
];
|
|
287
|
+
|
|
288
|
+
console.log('');
|
|
289
|
+
OutputHelper.info(`Promoting pins from "${fromTarget}" to "${toTarget}":`);
|
|
290
|
+
console.log(formatTable(changes, columns, { indent: ' ' }));
|
|
291
|
+
console.log('');
|
|
292
|
+
|
|
293
|
+
if (unmatched.length > 0) {
|
|
294
|
+
OutputHelper.warn(
|
|
295
|
+
`${unmatched.length} place${_plural(
|
|
296
|
+
unmatched.length
|
|
297
|
+
)} in "${toTarget}" had no matching pin in "${fromTarget}" (left unchanged): ` +
|
|
298
|
+
unmatched
|
|
299
|
+
.map((r) => `${r.placeLabel} (${r.basePlace.placeId})`)
|
|
300
|
+
.join(', ')
|
|
301
|
+
);
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
await _commitPinChangesAsync(configPath, config, changes, args);
|
|
305
|
+
}
|
|
306
|
+
|
|
307
|
+
function _formatChange(change: PinChange): string {
|
|
308
|
+
const from = change.from != null ? `v${change.from}` : '(latest)';
|
|
309
|
+
const unchanged = change.from === change.to ? ' (unchanged)' : '';
|
|
310
|
+
return `${from} → v${change.to}${unchanged}`;
|
|
311
|
+
}
|
|
@@ -1,10 +1,21 @@
|
|
|
1
1
|
import { describe, it, expect } from 'vitest';
|
|
2
|
+
import * as fs from 'fs/promises';
|
|
3
|
+
import * as os from 'os';
|
|
4
|
+
import * as path from 'path';
|
|
2
5
|
import {
|
|
3
6
|
type DeployConfig,
|
|
4
7
|
type DeployTarget,
|
|
8
|
+
loadDeployConfigAsync,
|
|
5
9
|
resolveDefaultTargetName,
|
|
6
10
|
} from './deploy-config.js';
|
|
7
11
|
|
|
12
|
+
async function writeTempConfigAsync(config: unknown): Promise<string> {
|
|
13
|
+
const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'deploy-config-test-'));
|
|
14
|
+
const configPath = path.join(dir, 'deploy.nevermore.json');
|
|
15
|
+
await fs.writeFile(configPath, JSON.stringify(config));
|
|
16
|
+
return configPath;
|
|
17
|
+
}
|
|
18
|
+
|
|
8
19
|
function makeTarget(placeId: number): DeployTarget {
|
|
9
20
|
return {
|
|
10
21
|
universeId: 1,
|
|
@@ -49,3 +60,90 @@ describe('resolveDefaultTargetName', () => {
|
|
|
49
60
|
expect(() => resolveDefaultTargetName(config)).toThrowError(/foo, bar/);
|
|
50
61
|
});
|
|
51
62
|
});
|
|
63
|
+
|
|
64
|
+
describe('loadDeployConfigAsync basePlace.version', () => {
|
|
65
|
+
it('accepts an omitted version pin', async () => {
|
|
66
|
+
const configPath = await writeTempConfigAsync({
|
|
67
|
+
targets: {
|
|
68
|
+
test: {
|
|
69
|
+
universeId: 1,
|
|
70
|
+
placeId: 2,
|
|
71
|
+
project: 'default.project.json',
|
|
72
|
+
basePlace: { universeId: 1, placeId: 3 },
|
|
73
|
+
},
|
|
74
|
+
},
|
|
75
|
+
});
|
|
76
|
+
const config = await loadDeployConfigAsync(configPath);
|
|
77
|
+
const target = config.targets['test'] as DeployTarget;
|
|
78
|
+
expect(target.basePlace?.version).toBeUndefined();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('accepts a positive integer version pin', async () => {
|
|
82
|
+
const configPath = await writeTempConfigAsync({
|
|
83
|
+
targets: {
|
|
84
|
+
test: {
|
|
85
|
+
universeId: 1,
|
|
86
|
+
placeId: 2,
|
|
87
|
+
project: 'default.project.json',
|
|
88
|
+
basePlace: { universeId: 1, placeId: 3, version: 42 },
|
|
89
|
+
},
|
|
90
|
+
},
|
|
91
|
+
});
|
|
92
|
+
const config = await loadDeployConfigAsync(configPath);
|
|
93
|
+
const target = config.targets['test'] as DeployTarget;
|
|
94
|
+
expect(target.basePlace?.version).toBe(42);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('rejects a non-integer version pin', async () => {
|
|
98
|
+
const configPath = await writeTempConfigAsync({
|
|
99
|
+
targets: {
|
|
100
|
+
test: {
|
|
101
|
+
universeId: 1,
|
|
102
|
+
placeId: 2,
|
|
103
|
+
project: 'default.project.json',
|
|
104
|
+
basePlace: { universeId: 1, placeId: 3, version: 1.5 },
|
|
105
|
+
},
|
|
106
|
+
},
|
|
107
|
+
});
|
|
108
|
+
await expect(loadDeployConfigAsync(configPath)).rejects.toThrowError(
|
|
109
|
+
/version.*positive integer/
|
|
110
|
+
);
|
|
111
|
+
});
|
|
112
|
+
|
|
113
|
+
it('rejects a zero or negative version pin', async () => {
|
|
114
|
+
const configPath = await writeTempConfigAsync({
|
|
115
|
+
targets: {
|
|
116
|
+
test: {
|
|
117
|
+
universeId: 1,
|
|
118
|
+
placeId: 2,
|
|
119
|
+
project: 'default.project.json',
|
|
120
|
+
basePlace: { universeId: 1, placeId: 3, version: 0 },
|
|
121
|
+
},
|
|
122
|
+
},
|
|
123
|
+
});
|
|
124
|
+
await expect(loadDeployConfigAsync(configPath)).rejects.toThrowError(
|
|
125
|
+
/version.*positive integer/
|
|
126
|
+
);
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('validates version pins on multi-place targets too', async () => {
|
|
130
|
+
const configPath = await writeTempConfigAsync({
|
|
131
|
+
targets: {
|
|
132
|
+
prod: {
|
|
133
|
+
places: [
|
|
134
|
+
{
|
|
135
|
+
name: 'chapter0',
|
|
136
|
+
universeId: 1,
|
|
137
|
+
placeId: 2,
|
|
138
|
+
project: 'default.project.json',
|
|
139
|
+
basePlace: { universeId: 1, placeId: 3, version: -1 },
|
|
140
|
+
},
|
|
141
|
+
],
|
|
142
|
+
},
|
|
143
|
+
},
|
|
144
|
+
});
|
|
145
|
+
await expect(loadDeployConfigAsync(configPath)).rejects.toThrowError(
|
|
146
|
+
/chapter0.*version.*positive integer/
|
|
147
|
+
);
|
|
148
|
+
});
|
|
149
|
+
});
|
|
@@ -4,6 +4,14 @@ import * as path from 'path';
|
|
|
4
4
|
export interface BasePlaceConfig {
|
|
5
5
|
universeId: number;
|
|
6
6
|
placeId: number;
|
|
7
|
+
/**
|
|
8
|
+
* Pin the base place to a specific published version. When set, the deploy
|
|
9
|
+
* downloads exactly this version instead of whatever is currently live, so
|
|
10
|
+
* builds are reproducible and a broken Studio edit can't leak into a deploy.
|
|
11
|
+
* Omit to always pull the latest version. Bump it with
|
|
12
|
+
* `nevermore deploy version upgrade`.
|
|
13
|
+
*/
|
|
14
|
+
version?: number;
|
|
7
15
|
}
|
|
8
16
|
|
|
9
17
|
export interface DeployTarget {
|
|
@@ -53,6 +61,15 @@ function _validatePlace(label: string, place: DeployTarget): void {
|
|
|
53
61
|
if (typeof place.basePlace.placeId !== 'number') {
|
|
54
62
|
throw new Error(`${label} basePlace is missing or has invalid "placeId"`);
|
|
55
63
|
}
|
|
64
|
+
if (
|
|
65
|
+
place.basePlace.version != null &&
|
|
66
|
+
(!Number.isInteger(place.basePlace.version) ||
|
|
67
|
+
place.basePlace.version < 1)
|
|
68
|
+
) {
|
|
69
|
+
throw new Error(
|
|
70
|
+
`${label} basePlace "version" must be a positive integer when set`
|
|
71
|
+
);
|
|
72
|
+
}
|
|
56
73
|
}
|
|
57
74
|
}
|
|
58
75
|
|
|
@@ -193,7 +193,11 @@ export abstract class BaseJobContext implements JobContext {
|
|
|
193
193
|
const resolvedName = options.packageName ?? '';
|
|
194
194
|
|
|
195
195
|
this._reporter.onPackagePhaseChange(resolvedName, 'downloading');
|
|
196
|
-
OutputHelper.verbose(
|
|
196
|
+
OutputHelper.verbose(
|
|
197
|
+
basePlace.version != null
|
|
198
|
+
? `Downloading base place (pinned v${basePlace.version}) for merge...`
|
|
199
|
+
: 'Downloading base place (latest) for merge...'
|
|
200
|
+
);
|
|
197
201
|
const buffer = await this._openCloudClient.downloadPlaceAsync(
|
|
198
202
|
basePlace.universeId,
|
|
199
203
|
basePlace.placeId,
|
|
@@ -203,7 +207,8 @@ export abstract class BaseJobContext implements JobContext {
|
|
|
203
207
|
transferredBytes: transferred,
|
|
204
208
|
totalBytes: total,
|
|
205
209
|
});
|
|
206
|
-
}
|
|
210
|
+
},
|
|
211
|
+
basePlace.version
|
|
207
212
|
);
|
|
208
213
|
const basePath = mergeContext.resolvePath('base.rbxl');
|
|
209
214
|
await fs.writeFile(basePath, buffer);
|
|
@@ -116,3 +116,135 @@ describe('OpenCloudClient.uploadPlaceAsync', () => {
|
|
|
116
116
|
expect(urls[1]).toContain('versionType=Saved');
|
|
117
117
|
});
|
|
118
118
|
});
|
|
119
|
+
|
|
120
|
+
describe('OpenCloudClient.downloadPlaceAsync', () => {
|
|
121
|
+
afterEach(() => {
|
|
122
|
+
vi.restoreAllMocks();
|
|
123
|
+
vi.unstubAllGlobals();
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it('downloads the latest version when no version is pinned', async () => {
|
|
127
|
+
const urls: string[] = [];
|
|
128
|
+
const fakeLimiter = {
|
|
129
|
+
fetchAsync: vi.fn(async (url: string | URL) => {
|
|
130
|
+
urls.push(String(url));
|
|
131
|
+
return new Response(JSON.stringify({ location: 'https://cdn/x' }), {
|
|
132
|
+
status: 200,
|
|
133
|
+
});
|
|
134
|
+
}),
|
|
135
|
+
} as unknown as RateLimiter;
|
|
136
|
+
vi.stubGlobal(
|
|
137
|
+
'fetch',
|
|
138
|
+
vi.fn(async () => new Response(Buffer.from([1, 2, 3]), { status: 200 }))
|
|
139
|
+
);
|
|
140
|
+
|
|
141
|
+
const client = new OpenCloudClient({
|
|
142
|
+
apiKey: 'test-key',
|
|
143
|
+
rateLimiter: fakeLimiter,
|
|
144
|
+
});
|
|
145
|
+
const buffer = await client.downloadPlaceAsync(1, 22);
|
|
146
|
+
|
|
147
|
+
expect(urls[0]).toBe(
|
|
148
|
+
'https://apis.roblox.com/asset-delivery-api/v1/assetId/22'
|
|
149
|
+
);
|
|
150
|
+
expect(urls[0]).not.toContain('/version/');
|
|
151
|
+
expect([...buffer]).toEqual([1, 2, 3]);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
it('downloads a specific version when pinned', async () => {
|
|
155
|
+
const urls: string[] = [];
|
|
156
|
+
const fakeLimiter = {
|
|
157
|
+
fetchAsync: vi.fn(async (url: string | URL) => {
|
|
158
|
+
urls.push(String(url));
|
|
159
|
+
return new Response(JSON.stringify({ location: 'https://cdn/x' }), {
|
|
160
|
+
status: 200,
|
|
161
|
+
});
|
|
162
|
+
}),
|
|
163
|
+
} as unknown as RateLimiter;
|
|
164
|
+
vi.stubGlobal(
|
|
165
|
+
'fetch',
|
|
166
|
+
vi.fn(async () => new Response(Buffer.from([9]), { status: 200 }))
|
|
167
|
+
);
|
|
168
|
+
|
|
169
|
+
const client = new OpenCloudClient({
|
|
170
|
+
apiKey: 'test-key',
|
|
171
|
+
rateLimiter: fakeLimiter,
|
|
172
|
+
});
|
|
173
|
+
await client.downloadPlaceAsync(1, 22, undefined, 42);
|
|
174
|
+
|
|
175
|
+
expect(urls[0]).toBe(
|
|
176
|
+
'https://apis.roblox.com/asset-delivery-api/v1/assetId/22/version/42'
|
|
177
|
+
);
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('throws a clear error when a pinned version does not exist', async () => {
|
|
181
|
+
// The Asset Delivery API reports a missing version as HTTP 200 with an
|
|
182
|
+
// errors array and no location.
|
|
183
|
+
const fakeLimiter = {
|
|
184
|
+
fetchAsync: vi.fn(
|
|
185
|
+
async () =>
|
|
186
|
+
new Response(
|
|
187
|
+
JSON.stringify({
|
|
188
|
+
errors: [{ code: 404, message: 'Request asset was not found' }],
|
|
189
|
+
}),
|
|
190
|
+
{ status: 200 }
|
|
191
|
+
)
|
|
192
|
+
),
|
|
193
|
+
} as unknown as RateLimiter;
|
|
194
|
+
|
|
195
|
+
const client = new OpenCloudClient({
|
|
196
|
+
apiKey: 'test-key',
|
|
197
|
+
rateLimiter: fakeLimiter,
|
|
198
|
+
});
|
|
199
|
+
|
|
200
|
+
await expect(
|
|
201
|
+
client.downloadPlaceAsync(1, 22, undefined, 999999)
|
|
202
|
+
).rejects.toThrowError(/no version 999999/);
|
|
203
|
+
});
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
describe('OpenCloudClient.getLatestPlaceVersionAsync', () => {
|
|
207
|
+
afterEach(() => {
|
|
208
|
+
vi.restoreAllMocks();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('returns the numeric revisionId from the Assets API', async () => {
|
|
212
|
+
const urls: string[] = [];
|
|
213
|
+
const fakeLimiter = {
|
|
214
|
+
fetchAsync: vi.fn(async (url: string | URL) => {
|
|
215
|
+
urls.push(String(url));
|
|
216
|
+
return new Response(
|
|
217
|
+
JSON.stringify({ assetId: '22', revisionId: '5781' }),
|
|
218
|
+
{ status: 200 }
|
|
219
|
+
);
|
|
220
|
+
}),
|
|
221
|
+
} as unknown as RateLimiter;
|
|
222
|
+
|
|
223
|
+
const client = new OpenCloudClient({
|
|
224
|
+
apiKey: 'test-key',
|
|
225
|
+
rateLimiter: fakeLimiter,
|
|
226
|
+
});
|
|
227
|
+
const version = await client.getLatestPlaceVersionAsync(1, 22);
|
|
228
|
+
|
|
229
|
+
expect(version).toBe(5781);
|
|
230
|
+
expect(urls[0]).toBe('https://apis.roblox.com/assets/v1/assets/22');
|
|
231
|
+
});
|
|
232
|
+
|
|
233
|
+
it('throws when the Assets API omits a revisionId', async () => {
|
|
234
|
+
const fakeLimiter = {
|
|
235
|
+
fetchAsync: vi.fn(
|
|
236
|
+
async () =>
|
|
237
|
+
new Response(JSON.stringify({ assetId: '22' }), { status: 200 })
|
|
238
|
+
),
|
|
239
|
+
} as unknown as RateLimiter;
|
|
240
|
+
|
|
241
|
+
const client = new OpenCloudClient({
|
|
242
|
+
apiKey: 'test-key',
|
|
243
|
+
rateLimiter: fakeLimiter,
|
|
244
|
+
});
|
|
245
|
+
|
|
246
|
+
await expect(client.getLatestPlaceVersionAsync(1, 22)).rejects.toThrowError(
|
|
247
|
+
/no revisionId/
|
|
248
|
+
);
|
|
249
|
+
});
|
|
250
|
+
});
|