@synopackageland/cli 0.41.9 → 0.41.11

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.
@@ -1,7 +1,11 @@
1
1
  import { CtlError } from '../ctl/errors.js';
2
2
  import { readFileSync } from "node:fs";
3
+ import { terminalSafe } from '../terminal-text.js';
4
+ import { pullContainerImages } from './container-pull.js';
5
+ import { savePullFailure } from './package-diagnostics.js';
3
6
  import { dsmFetch } from "./dsm-http.js";
4
7
  import { assertDsmCompoundResponse } from "./dsm-compound.js";
8
+ import { dsmRequestTimeoutMs, dsmOperationTimeoutMs, dsmInstallTimeoutMs, dsmUninstallTimeoutMs, dsmPollIntervalMs, isHostTimeout } from '../nas/timeouts.js';
5
9
  function normalizeDeppkgs(deppkgs) {
6
10
  if (typeof deppkgs === "string") {
7
11
  const match = /^([^><=]+)(>=|>|<=|<|==)(.+)$/.exec(deppkgs.trim());
@@ -25,13 +29,13 @@ async function dsmWebApi(dsmOrigin, cookieHeader, params, options = {}) {
25
29
  const query = new URLSearchParams(params);
26
30
  const response = await dsmFetch(`${dsmOrigin}/webapi/entry.cgi?${query.toString()}`, {
27
31
  headers: { cookie: cookieHeader },
28
- });
32
+ }, options.timeoutMs);
29
33
  const body = (await response.json());
30
34
  const errorCode = body.error?.code;
31
35
  if (!body.success &&
32
36
  !options.allowFailure &&
33
37
  !(errorCode && options.allowErrorCodes?.includes(errorCode))) {
34
- throw new Error(`DSM ${params.api}/${params.method} failed: ${Number(body.error?.code) || "invalid response"}`);
38
+ throw new CtlError('DSM_API_FAILED', `DSM ${params.api}/${params.method} failed: ${Number(body.error?.code) || "invalid response"}${typeof body.error?.description === "string" ? ` ${body.error.description}` : ""}`, 'Check DSM permissions and Package Center');
35
39
  }
36
40
  return body;
37
41
  }
@@ -70,7 +74,8 @@ export function packageStateFromRecord(value) {
70
74
  const installedPath = stringValue(installedInfo.path, match.installedPath);
71
75
  const version = stringValue(match.version);
72
76
  const status = stringValue(additional.status, match.rawStatus, match.status);
73
- const error = packageError(additional.error ?? match.error);
77
+ const error = packageError({ ...record(additional.error ?? match.error),
78
+ worker_msg: record(additional.error ?? match.error).worker_msg ?? record(additional.error ?? match.error).workerMsg ?? additional.worker_msg ?? match.worker_msg });
74
79
  const state = {
75
80
  installed: Boolean(installedPath) || match.installed === true,
76
81
  };
@@ -110,7 +115,7 @@ export function packageStateFromListPayload(payload, packageId) {
110
115
  }
111
116
  return packageStateFromRecord(match);
112
117
  }
113
- export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId) {
118
+ export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packageId, timeoutMs) {
114
119
  try {
115
120
  const body = await dsmWebApi(dsmOrigin, cookieHeader, {
116
121
  api: "SYNO.Core.Package",
@@ -118,7 +123,7 @@ export async function fetchInstalledPackageState(dsmOrigin, cookieHeader, packag
118
123
  method: "list",
119
124
  additional: JSON.stringify(["status", "installed_info"]),
120
125
  ignore_hidden: "false",
121
- });
126
+ }, { timeoutMs });
122
127
  return packageStateFromListPayload(body, packageId);
123
128
  }
124
129
  catch (error) {
@@ -133,89 +138,143 @@ function installStatusIsBroken(status) {
133
138
  }
134
139
  return /(broken|error|fail|150)/i.test(status);
135
140
  }
141
+ function packageFailure(state) {
142
+ return installStatusIsBroken(state.status) || state.statusCode === 150 ||
143
+ Boolean(state.statusCode && /broken|error|fail|corruption/i.test(state.statusDescription ?? '')) ||
144
+ Boolean(state.error && (state.error.code !== undefined ? state.error.code !== 0 : state.error.description));
145
+ }
146
+ function failureDetail(state) {
147
+ return [state.error?.code ?? state.statusCode, state.error?.description ?? state.statusDescription,
148
+ ...(state.error?.workerMsg ?? []).map(value => typeof value === 'string' ? value : JSON.stringify(value))].filter(value => value !== undefined).join(' ');
149
+ }
150
+ function installInProgress(state) {
151
+ return /^(installing|upgrading|starting)$/i.test(state.status ?? '');
152
+ }
153
+ function installBudgetExpired(options) {
154
+ const target = options.expectedVersion ? ` to reach version ${options.expectedVersion}` : ' to install from catalog';
155
+ return new CtlError('DSM_INSTALL_TIMEOUT', `Timed out waiting for ${options.packageId}${target}: overall install budget exhausted; DSM may still be installing/starting.`, 'Check Package Center state before retrying; the install was not replayed');
156
+ }
136
157
  export async function waitForInstalledVersion(readState, options) {
137
- const deadline = Date.now() + (options.deadlineMs ?? 15 * 60 * 1000);
138
- const pollMs = options.pollMs ?? 5000;
158
+ const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
159
+ const pollMs = options.pollMs ?? dsmPollIntervalMs();
160
+ let transitioned = !options.requireTransition;
139
161
  while (Date.now() < deadline) {
140
- const state = await readState();
141
- if (state?.installed && installStatusIsBroken(state.status)) {
142
- throw new Error(`Package ${options.packageId} install did not complete (Package Center status ${state.status}). Check synopkg postinst / webservice acquire.`);
162
+ let state;
163
+ try {
164
+ state = await readState(Math.max(1, deadline - Date.now()));
143
165
  }
144
- if (state?.installed && !/installing|upgrading/i.test(state.status ?? "")) {
145
- if (!options.expectedVersion || state.version === options.expectedVersion) {
146
- return;
147
- }
166
+ catch (error) {
167
+ if (!isHostTimeout(error))
168
+ throw error;
169
+ options.onProgress?.(`${options.packageId}: status request timed out; still installing/starting…`);
148
170
  }
149
- await new Promise((resolve) => setTimeout(resolve, pollMs));
171
+ if (state && !installInProgress(state) && packageFailure(state)) {
172
+ throw new CtlError('DSM_INSTALL_FAILED', `DSM reports package ${options.packageId} install did not complete (Package Center status ${state.status ?? 'unknown'}): ${failureDetail(state)}`.trim(), 'Check the DSM Package Center install/start log');
173
+ }
174
+ const running = /^(run|running)$/i.test(state?.status ?? '');
175
+ if (state && (!state.installed || (state.status !== undefined && !running) ||
176
+ (state.version !== undefined && state.version !== options.expectedVersion)))
177
+ transitioned = true;
178
+ if (Date.now() >= deadline)
179
+ break;
180
+ if (state?.installed && !/installing|upgrading|starting/i.test(state.status ?? '') &&
181
+ (!options.expectedVersion || state.version === options.expectedVersion) &&
182
+ (!options.requireRunning || running) && transitioned)
183
+ return;
184
+ options.onProgress?.(`${options.packageId}: installing/starting… (DSM status ${state?.status ?? 'unavailable'})`);
185
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
150
186
  }
151
- throw new Error(options.expectedVersion
152
- ? `Timed out waiting for ${options.packageId} to reach version ${options.expectedVersion}`
153
- : `Timed out waiting for ${options.packageId} to install from catalog`);
187
+ throw installBudgetExpired(options);
154
188
  }
155
189
  function packageStatusIsStopped(state) {
156
190
  const status = state?.status?.trim().toLowerCase();
157
191
  return state?.installed === true && (status === "stop" || status === "stopped" || status === "manually-stopped");
158
192
  }
159
- async function waitForPackageStopped(readState, options) {
160
- const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
161
- const pollMs = options.pollMs ?? 1000;
162
- while (Date.now() < deadline) {
163
- const state = await readState();
164
- if (packageStatusIsStopped(state)) {
165
- return;
166
- }
167
- await new Promise((resolve) => setTimeout(resolve, pollMs));
168
- }
169
- throw new Error(`Timed out waiting for ${options.packageId} to stop before uninstall`);
170
- }
171
- async function waitForPackageUninstalled(readState, options) {
172
- const deadline = Date.now() + (options.deadlineMs ?? 5 * 60 * 1000);
173
- const pollMs = options.pollMs ?? 1000;
174
- while (Date.now() < deadline) {
175
- const state = await readState();
176
- if (state?.installed === false) {
177
- return;
178
- }
179
- await new Promise((resolve) => setTimeout(resolve, pollMs));
180
- }
181
- throw new Error(`Timed out waiting for ${options.packageId} to be uninstalled`);
182
- }
183
193
  function uninstallFailure(packageId, error) {
184
194
  const detail = error instanceof Error ? error.message : String(error);
195
+ if (error instanceof CtlError)
196
+ return new CtlError(error.code, `Failed to uninstall ${packageId}: ${detail}`, error.hint, error.exitCode);
185
197
  const failure = new Error(`Failed to uninstall ${packageId}: ${detail}`);
186
198
  failure.code = "CATALOG_INSTALL_FAILED";
187
199
  return failure;
188
200
  }
189
201
  export async function uninstallPackageFromDsm(options) {
202
+ const deadline = Date.now() + (options.deadlineMs ?? dsmUninstallTimeoutMs());
203
+ const requestMs = dsmRequestTimeoutMs(), operationMs = dsmOperationTimeoutMs();
204
+ const pollMs = options.pollMs ?? dsmPollIntervalMs();
205
+ const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
206
+ let phase = 'checking package state';
207
+ let previousFailure;
208
+ const expired = () => new CtlError('DSM_UNINSTALL_TIMEOUT', `Package ${options.packageId}: overall uninstall budget exhausted while ${phase}; DSM may still be stopping/uninstalling.`, 'Check Package Center before retrying; stop/uninstall requests were not replayed');
209
+ const remaining = (limit) => {
210
+ if (Date.now() >= deadline)
211
+ throw expired();
212
+ return Math.min(limit, deadline - Date.now());
213
+ };
214
+ const heartbeat = setInterval(() => progress(`${options.packageId}: ${phase}…`), pollMs);
190
215
  try {
191
216
  const sid = extractSid(options.cookieHeader);
192
- const readState = () => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
193
- const before = await readState();
194
- if (!before)
195
- throw new Error("Cannot verify package state before removal");
196
- if (!packageStatusIsStopped(before)) {
197
- await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
198
- api: "SYNO.Core.Package.Control",
199
- version: "1",
200
- method: "stop",
201
- id: jsonApiParam(options.packageId),
202
- _sid: sid,
203
- });
204
- await waitForPackageStopped(readState, options);
217
+ const wait = async (done) => {
218
+ while (Date.now() < deadline) {
219
+ let state;
220
+ try {
221
+ state = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, remaining(requestMs));
222
+ }
223
+ catch (error) {
224
+ if (!isHostTimeout(error))
225
+ throw error;
226
+ progress(`${options.packageId}: status request timed out; ${phase}…`);
227
+ }
228
+ if (state && Date.now() < deadline) {
229
+ if (done(state))
230
+ return state;
231
+ if (packageFailure(state) && !(phase === 'uninstalling' && previousFailure === failureDetail(state)))
232
+ throw new CtlError('DSM_UNINSTALL_FAILED', `DSM reports ${options.packageId} failed while ${phase}: ${failureDetail(state) || state.status}`, 'Check the DSM Package Center stop/uninstall log');
233
+ }
234
+ progress(`${options.packageId}: ${phase}… (DSM status ${state?.status ?? 'unavailable'})`);
235
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, Math.max(0, deadline - Date.now()))));
236
+ }
237
+ throw expired();
238
+ };
239
+ const dispatch = async (api, method) => {
240
+ progress(`${options.packageId}: ${phase}…`);
241
+ try {
242
+ await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
243
+ api, version: '1', method, id: jsonApiParam(options.packageId), _sid: sid,
244
+ ...(method === 'uninstall' ? { extra_values: jsonApiParam('{}') } : {}),
245
+ }, { timeoutMs: remaining(operationMs) });
246
+ }
247
+ catch (error) {
248
+ if (!isHostTimeout(error))
249
+ throw error;
250
+ progress(`${options.packageId}: ${method} request timed out; checking DSM state without replay…`);
251
+ }
252
+ };
253
+ const before = await wait(() => true);
254
+ if (!before.installed)
255
+ return;
256
+ const broken = packageFailure(before) && !installInProgress(before);
257
+ if (broken) {
258
+ previousFailure = failureDetail(before);
259
+ progress(`${options.packageId}: broken package (${previousFailure || before.status}); uninstalling directly without stop…`);
205
260
  }
206
- await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
207
- api: "SYNO.Core.Package.Uninstallation",
208
- version: "1",
209
- method: "uninstall",
210
- id: jsonApiParam(options.packageId),
211
- extra_values: jsonApiParam("{}"),
212
- _sid: sid,
213
- });
214
- await waitForPackageUninstalled(readState, options);
261
+ if (!broken && !packageStatusIsStopped(before)) {
262
+ phase = 'stopping';
263
+ await dispatch('SYNO.Core.Package.Control', 'stop');
264
+ const stopped = await wait(state => !state.installed || packageStatusIsStopped(state));
265
+ if (!stopped.installed)
266
+ return;
267
+ }
268
+ phase = 'uninstalling';
269
+ await dispatch('SYNO.Core.Package.Uninstallation', 'uninstall');
270
+ await wait(state => !state.installed);
215
271
  }
216
272
  catch (error) {
217
273
  throw uninstallFailure(options.packageId, error);
218
274
  }
275
+ finally {
276
+ clearInterval(heartbeat);
277
+ }
219
278
  }
220
279
  export function catalogInstallationRequests(entry, installed, sid, volumePath) {
221
280
  const install = {
@@ -297,7 +356,7 @@ export function catalogCompoundSteps(uploaded, entry, options) {
297
356
  export function catalogUpgradeCompoundSteps(uploaded, entry, extraValues) {
298
357
  return catalogCompoundSteps(uploaded, entry, { blupgrade: true, extraValues });
299
358
  }
300
- async function uploadLocalSpk(options, sid) {
359
+ async function uploadLocalSpk(options, sid, timeoutMs) {
301
360
  const bytes = readFileSync(options.spkPath);
302
361
  const form = new FormData();
303
362
  form.append("api", "SYNO.Core.Package.Installation");
@@ -309,7 +368,7 @@ async function uploadLocalSpk(options, sid) {
309
368
  method: "POST",
310
369
  headers: { cookie: options.cookieHeader },
311
370
  body: form,
312
- });
371
+ }, timeoutMs);
313
372
  const body = (await uploadResponse.json());
314
373
  if (!body.success || !body.data?.id || !body.data.task_id) {
315
374
  throw new Error(`DSM SYNO.Core.Package.Installation/upload failed: ${Number(body.error?.code) || "invalid response"}`);
@@ -317,20 +376,81 @@ async function uploadLocalSpk(options, sid) {
317
376
  return { id: body.data.id, taskId: body.data.task_id };
318
377
  }
319
378
  export async function installPackageFromLocalSpk(options) {
320
- const sid = extractSid(options.cookieHeader);
321
- const dsmState = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId);
322
- const installed = dsmState?.installed === true;
323
- const uploaded = await uploadLocalSpk(options, sid);
324
- const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: installed, extraValues: options.extraValues }));
325
- const compoundResponse = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
326
- api: "SYNO.Entry.Request",
327
- version: "1",
328
- method: "request",
329
- stop_when_error: "true",
330
- mode: jsonApiParam("sequential"),
331
- compound,
332
- _sid: sid,
333
- }, { allowFailure: true });
334
- assertDsmCompoundResponse(compoundResponse);
335
- await waitForInstalledVersion(async () => await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId), { packageId: options.packageId, expectedVersion: options.version });
379
+ // The same wall budget covers upload, install/upgrade and startup verification.
380
+ const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
381
+ const requestMs = dsmRequestTimeoutMs(), operationMs = dsmOperationTimeoutMs();
382
+ const pollMs = options.pollMs ?? dsmPollIntervalMs();
383
+ const progress = options.onProgress ?? (message => process.stderr.write(`${terminalSafe(message)}\n`));
384
+ let phase = 'checking package state';
385
+ const remaining = (limit) => {
386
+ if (Date.now() >= deadline)
387
+ throw installBudgetExpired(options);
388
+ return Math.min(limit, deadline - Date.now());
389
+ };
390
+ const heartbeat = setInterval(() => progress(`${options.packageId}: ${phase}…`), pollMs);
391
+ try {
392
+ const sid = extractSid(options.cookieHeader);
393
+ let before;
394
+ while (!before) {
395
+ try {
396
+ before = await fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, remaining(requestMs));
397
+ }
398
+ catch (error) {
399
+ if (!isHostTimeout(error))
400
+ throw error;
401
+ }
402
+ if (!before) {
403
+ progress(`${options.packageId}: waiting for DSM package state…`);
404
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining(requestMs))));
405
+ }
406
+ }
407
+ if (options.containerImages?.length) {
408
+ phase = 'pulling container images';
409
+ try {
410
+ await pullContainerImages({ ...options, images: options.containerImages,
411
+ deadlineMs: remaining(Number.MAX_SAFE_INTEGER), pollMs, onProgress: progress });
412
+ savePullFailure(options.dsmOrigin, options.packageId);
413
+ }
414
+ catch (error) {
415
+ savePullFailure(options.dsmOrigin, options.packageId, error instanceof Error ? error.message : 'Unknown pull error');
416
+ throw error;
417
+ }
418
+ }
419
+ phase = 'uploading';
420
+ progress(`${options.packageId}: uploading…`);
421
+ const uploaded = await uploadLocalSpk(options, sid, remaining(operationMs));
422
+ const compound = JSON.stringify(catalogCompoundSteps(uploaded, { version: options.version, size: readFileSync(options.spkPath).byteLength }, { blupgrade: before.installed, extraValues: options.extraValues }));
423
+ phase = 'installing/starting';
424
+ progress(`${options.packageId}: ${phase}…`);
425
+ let timedOut = false;
426
+ try {
427
+ const response = await dsmWebApi(options.dsmOrigin, options.cookieHeader, {
428
+ api: 'SYNO.Entry.Request', version: '1', method: 'request', stop_when_error: 'true',
429
+ mode: jsonApiParam('sequential'), compound, _sid: sid,
430
+ }, { allowFailure: true, timeoutMs: remaining(operationMs) });
431
+ assertDsmCompoundResponse(response);
432
+ }
433
+ catch (error) {
434
+ if (!isHostTimeout(error))
435
+ throw error;
436
+ timedOut = true;
437
+ progress(`${options.packageId}: install/upgrade request timed out; DSM may still be installing/starting; checking state…`);
438
+ }
439
+ await waitForInstalledVersion(budget => fetchInstalledPackageState(options.dsmOrigin, options.cookieHeader, options.packageId, Math.min(requestMs, budget)), { packageId: options.packageId, expectedVersion: options.version,
440
+ deadlineMs: remaining(Number.MAX_SAFE_INTEGER), pollMs, requireRunning: true,
441
+ // A lost same-version reinstall reply must not accept the old running instance.
442
+ requireTransition: timedOut && before.installed && before.version === options.version,
443
+ onProgress: progress });
444
+ }
445
+ catch (error) {
446
+ if (isHostTimeout(error)) {
447
+ if (Date.now() >= deadline)
448
+ throw installBudgetExpired(options);
449
+ throw new CtlError('HOST_TIMEOUT', 'SPK upload request timed out; no install request was sent', 'Check the NAS connection and DSM_OPERATION_TIMEOUT_SECONDS before retrying');
450
+ }
451
+ throw error;
452
+ }
453
+ finally {
454
+ clearInterval(heartbeat);
455
+ }
336
456
  }
@@ -0,0 +1,18 @@
1
+ /** Use inspected effective references: these include the lock's digest and mirror. */
2
+ export declare function containerImageReferences(report: {
3
+ compose?: {
4
+ images: unknown[];
5
+ };
6
+ }): string[];
7
+ interface PullOptions {
8
+ dsmOrigin: string;
9
+ cookieHeader: string;
10
+ images: string[];
11
+ deadlineMs?: number;
12
+ stallMs?: number;
13
+ pollMs?: number;
14
+ onProgress?: (message: string) => void;
15
+ }
16
+ /** Pull on the NAS, before Package Center can turn a registry failure into broken/150. */
17
+ export declare function pullContainerImages(options: PullOptions): Promise<void>;
18
+ export {};
@@ -0,0 +1,120 @@
1
+ import { CtlError } from '../ctl/errors.js';
2
+ import { parseImageReference } from '../images/pin.js';
3
+ import { registryBaseUrl } from '../compose/registry-mirrors.js';
4
+ import { terminalSafe } from '../terminal-text.js';
5
+ import { maskDiagnostic } from './package-diagnostics.js';
6
+ import { dsmFetch } from './dsm-http.js';
7
+ import { dsmRequestTimeoutMs, dsmInstallTimeoutMs, dsmPollIntervalMs, dsmPullStallTimeoutMs, isHostTimeout } from '../nas/timeouts.js';
8
+ /** Use inspected effective references: these include the lock's digest and mirror. */
9
+ export function containerImageReferences(report) {
10
+ return [...new Set((report.compose?.images ?? []).map(value => {
11
+ const reference = value && typeof value === 'object' ? value.reference : undefined;
12
+ if (typeof reference !== 'string' || !parseImageReference(reference))
13
+ throw new CtlError('CONTAINER_PULL_FAILED', 'Invalid container image reference in inspected SPK', 'Rebuild and inspect the SPK');
14
+ return reference;
15
+ }))];
16
+ }
17
+ const hint = 'Configure a reachable registry mirror, trust the inspecting CA in Container Manager, or use images pin on a trusted host and preload the pinned image on the NAS. Check Container Manager before retrying; pull tasks may still be running.';
18
+ const causePattern = /error|fail|denied|forbidden|unauthorized|x509|certificate|tls|timeout|timed out|refused|no such host|not found|too many requests|429|403|unexpected.*status/i;
19
+ /** Pull on the NAS, before Package Center can turn a registry failure into broken/150. */
20
+ export async function pullContainerImages(options) {
21
+ const deadline = Date.now() + (options.deadlineMs ?? dsmInstallTimeoutMs());
22
+ const stallMs = options.stallMs ?? dsmPullStallTimeoutMs();
23
+ const pollMs = options.pollMs ?? dsmPollIntervalMs();
24
+ const progress = options.onProgress ?? (line => process.stderr.write(`${terminalSafe(line)}\n`));
25
+ for (const image of new Set(options.images)) {
26
+ const parsed = parseImageReference(image);
27
+ if (!parsed)
28
+ throw new CtlError('CONTAINER_PULL_FAILED', 'Invalid container image reference', hint);
29
+ // Do not apply build-host mirror overrides here: the inspected reference is authoritative.
30
+ const requested = registryBaseUrl(parsed.registry, {});
31
+ const context = `Image ${image} (requested registry ${requested}; actual endpoint appears in the daemon detail when available)`;
32
+ const fail = (code, detail) => new CtlError(code, maskDiagnostic(`${context}: ${detail}`), hint);
33
+ let lastAdvance = Date.now(), downloaded = -1;
34
+ const remaining = () => {
35
+ if (Date.now() >= deadline)
36
+ throw fail('CONTAINER_PULL_TIMEOUT', 'overall install/pull budget exhausted; NAS pull outcome may still be pending');
37
+ if (Date.now() - lastAdvance >= stallMs)
38
+ throw fail('CONTAINER_PULL_STALLED', `no download progress for ${stallMs / 1000} seconds; NAS pull may still be running`);
39
+ return Math.max(1, Math.min(dsmRequestTimeoutMs(), deadline - Date.now(), stallMs - (Date.now() - lastAdvance)));
40
+ };
41
+ const call = async (method, params) => {
42
+ const response = await dsmFetch(`${options.dsmOrigin}/webapi/entry.cgi`, {
43
+ method: 'POST', headers: { cookie: options.cookieHeader, 'content-type': 'application/x-www-form-urlencoded' },
44
+ body: new URLSearchParams({ api: 'SYNO.Docker.Image', version: '1', method, ...params }),
45
+ }, remaining());
46
+ const body = await response.json();
47
+ if (!response.ok || body?.success !== true) {
48
+ const error = body?.error;
49
+ throw fail('CONTAINER_PULL_FAILED', `Container Manager ${method}: HTTP ${response.status}, DSM ${error?.code ?? 'unknown'} ${typeof error?.description === 'string' ? error.description : ''}; cause unknown if not supplied by DSM`);
50
+ }
51
+ return body.data;
52
+ };
53
+ progress(`${context}: starting NAS image pull…`);
54
+ // Even while a NAS request is pending, keep stderr alive.
55
+ const heartbeat = setInterval(() => progress(`${context}: pulling… (${Math.max(0, downloaded)} bytes)`), pollMs);
56
+ try {
57
+ if (parsed.digest) {
58
+ const inventory = await call('list', { offset: '0', limit: '-1' });
59
+ const cached = Array.isArray(inventory?.images) && inventory.images.some((entry) => {
60
+ if (typeof entry?.repository !== 'string' || typeof entry.digest !== 'string')
61
+ return false;
62
+ const local = parseImageReference(`${entry.repository}@${entry.digest}`);
63
+ return local?.registry === parsed.registry && local.repository === parsed.repository && local.digest === parsed.digest;
64
+ });
65
+ if (cached) {
66
+ progress(`${context}: pinned digest already present on NAS; no registry pull needed`);
67
+ continue;
68
+ }
69
+ }
70
+ let task;
71
+ try {
72
+ task = await call('pull_start', { repository: `${parsed.registry}/${parsed.repository}`, tag: parsed.digest ?? parsed.tag });
73
+ }
74
+ catch (error) {
75
+ if (isHostTimeout(error))
76
+ throw fail('CONTAINER_PULL_FAILED', 'pull_start response timed out; outcome unknown, request not replayed and package install not sent');
77
+ throw error;
78
+ }
79
+ if (typeof task?.task_id !== 'string' || !task.task_id)
80
+ throw fail('CONTAINER_PULL_FAILED', 'invalid pull_start response: task_id missing');
81
+ lastAdvance = Date.now();
82
+ while (true) {
83
+ remaining();
84
+ let state;
85
+ try {
86
+ state = await call('pull_status', { task_id: task.task_id });
87
+ }
88
+ catch (error) {
89
+ if (!isHostTimeout(error))
90
+ throw error;
91
+ progress(`${context}: status request timed out; checking existing pull task…`);
92
+ }
93
+ if (state !== undefined) {
94
+ if (typeof state?.finished !== 'boolean')
95
+ throw fail('CONTAINER_PULL_FAILED', 'invalid pull_status response: finished missing');
96
+ const detail = typeof state.description === 'string' ? state.description : '';
97
+ if (state.error || state.downloaded < 0 || causePattern.test(detail))
98
+ throw fail('CONTAINER_PULL_FAILED', detail || 'daemon reported a pull error; cause unknown');
99
+ if (typeof state.downloaded === 'number' && Number.isFinite(state.downloaded) && state.downloaded > downloaded) {
100
+ downloaded = state.downloaded;
101
+ lastAdvance = Date.now();
102
+ }
103
+ progress(maskDiagnostic(`${context}: ${state.finished ? 'pull complete' : 'pulling'} (${Math.max(0, downloaded)} bytes)${detail ? ` — ${detail}` : ''}`));
104
+ remaining();
105
+ if (state.finished)
106
+ break;
107
+ }
108
+ await new Promise(resolve => setTimeout(resolve, Math.min(pollMs, remaining())));
109
+ }
110
+ }
111
+ catch (error) {
112
+ if (error instanceof CtlError && error.code.startsWith('CONTAINER_PULL_'))
113
+ throw error;
114
+ throw fail('CONTAINER_PULL_FAILED', `NAS image pull failed: ${error instanceof Error ? error.message : 'unknown transport error'}`);
115
+ }
116
+ finally {
117
+ clearInterval(heartbeat);
118
+ }
119
+ }
120
+ }
@@ -1,5 +1,7 @@
1
1
  import { sessionForCredentials } from "../nas/credentials.js";
2
2
  import { CtlError } from '../ctl/errors.js';
3
+ import { inspectAnySpk } from '../spk/inspect.js';
4
+ import { containerImageReferences } from './container-pull.js';
3
5
  import { listShareNames, lookupSharePath, } from "./share-lookup.js";
4
6
  import { createDsmApiCall, fetchSharePermissions, fetchUserGroups, } from "./share-permission-lookup.js";
5
7
  import { fetchPortInfo } from "./port-info-lookup.js";
@@ -122,6 +124,7 @@ function createDsmNasHost(credentials, profileName) {
122
124
  version: input.version,
123
125
  profileName,
124
126
  extraValues: input.extraValues,
127
+ containerImages: containerImageReferences(await inspectAnySpk(input.spkPath)),
125
128
  });
126
129
  },
127
130
  async getExecutorStatus(packageIdentity) {
@@ -58,7 +58,7 @@ export function assertDsmCompoundResponse(raw) {
58
58
  const root = requireRecord(raw, "response");
59
59
  if (root.success !== true) {
60
60
  const errorCode = numericErrorCode(root.error);
61
- throw new DsmCompoundError("DSM_REQUEST_FAILED", "DSM SYNO.Entry.Request compound request failed", {
61
+ throw new DsmCompoundError("DSM_REQUEST_FAILED", `DSM SYNO.Entry.Request compound request failed${errorSummary(root.error)}`, {
62
62
  dsmErrorCode: errorCode,
63
63
  detail: root.error ?? root,
64
64
  });
@@ -68,7 +68,7 @@ export function assertDsmCompoundResponse(raw) {
68
68
  const dependencyConflictIndex = parsed.results.findIndex((result) => result.errorCode === 4552);
69
69
  if (dependencyConflictIndex >= 0) {
70
70
  const result = parsed.results[dependencyConflictIndex];
71
- throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running`, {
71
+ throw new DsmCompoundError("DSM_DEPENDENCY_CONFLICT", `DSM ${result.api}/${result.method} rejected the package because a dependency is running (4552)`, {
72
72
  api: result.api,
73
73
  method: result.method,
74
74
  errorCode: result.errorCode,
@@ -80,7 +80,7 @@ export function assertDsmCompoundResponse(raw) {
80
80
  if (parsed.hasFail || firstFailureIndex >= 0) {
81
81
  const result = firstFailureIndex >= 0 ? parsed.results[firstFailureIndex] : undefined;
82
82
  throw new DsmCompoundError("DSM_COMPOUND_FAILED", result
83
- ? `DSM compound step ${result.api}/${result.method} failed`
83
+ ? `DSM compound step ${result.api}/${result.method} failed${errorSummary(failureDetail(resultRecords[firstFailureIndex]))}`
84
84
  : "DSM compound request reported a failed step", {
85
85
  api: result?.api,
86
86
  method: result?.method,
@@ -90,6 +90,13 @@ export function assertDsmCompoundResponse(raw) {
90
90
  }
91
91
  return parsed;
92
92
  }
93
+ function errorSummary(error) {
94
+ if (!isRecord(error))
95
+ return '';
96
+ const code = numericErrorCode(error);
97
+ const description = typeof error.description === 'string' ? error.description : '';
98
+ return `${code === undefined ? '' : ` (${code})`}${description ? `: ${description}` : ''}`;
99
+ }
93
100
  function failureDetail(rawResult) {
94
101
  const result = isRecord(rawResult) ? rawResult : undefined;
95
102
  return result?.error ?? rawResult;
@@ -4,6 +4,7 @@ import { join, resolve } from "node:path";
4
4
  import { createInterface } from "node:readline/promises";
5
5
  import { CtlError } from "../ctl/errors.js";
6
6
  import { inspectAnySpk } from "../spk/inspect.js";
7
+ import { containerImageReferences } from './container-pull.js';
7
8
  import { connectNas, requireAdministrator, } from "./connection.js";
8
9
  import { fetchPackageSource } from "../package-source.js";
9
10
  import { fetchInstalledPackageState, installPackageFromLocalSpk, uninstallPackageFromDsm, } from "./catalog-install.js";
@@ -142,6 +143,7 @@ export async function installPackage(spec, options = {}, connection) {
142
143
  await confirm(`Install ${plan.map((e) => e.package + "@" + e.version).join(", ")}`, options.yes);
143
144
  // Download, verify and inspect the entire plan before the first NAS write.
144
145
  const files = new Map();
146
+ const images = new Map();
145
147
  for (const entry of plan) {
146
148
  let file = localFile;
147
149
  if (!local || entry.package !== root.package) {
@@ -159,6 +161,7 @@ export async function installPackage(spec, options = {}, connection) {
159
161
  checked.version !== entry.version)
160
162
  throw fail("PACKAGE_IDENTITY_MISMATCH", "Downloaded SPK identity differs from the selected release");
161
163
  files.set(entry.package, file);
164
+ images.set(entry.package, containerImageReferences(checked));
162
165
  }
163
166
  for (const entry of plan)
164
167
  await installPackageFromLocalSpk({
@@ -167,6 +170,7 @@ export async function installPackage(spec, options = {}, connection) {
167
170
  packageId: entry.package,
168
171
  version: entry.version,
169
172
  spkPath: files.get(entry.package),
173
+ containerImages: images.get(entry.package),
170
174
  extraValues: entry.package === root.package ? options.param : undefined,
171
175
  });
172
176
  return {
@@ -0,0 +1,5 @@
1
+ import type { InstalledPackageState } from './catalog-install.js';
2
+ /** Diagnostic text is untrusted and can contain daemon URLs or echoed credentials. */
3
+ export declare function maskDiagnostic(text: string): string;
4
+ export declare function savePullFailure(origin: string, packageId: string, message?: string): void;
5
+ export declare function packageDiagnostics(packageId: string, state: InstalledPackageState | undefined, origin: string): Record<string, unknown>;