@danypops/pi-packed 0.22.6 → 0.24.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/extension/src/vehicle-tools.ts +4 -5
- package/package.json +2 -2
- package/service/src/cli/cli.ts +25 -1
- package/service/src/daemon/client.ts +9 -0
- package/service/src/daemon/service.ts +62 -0
- package/service/src/daemon/vehicle-registration.ts +5 -0
- package/service/src/packages/install.ts +83 -12
- package/service/src/packages/package.ts +9 -0
- package/service/src/security/security.ts +2 -0
- package/service/test/cli.test.ts +69 -0
- package/service/test/daemon-kit-migration.test.ts +1 -0
- package/service/test/install.test.ts +98 -2
- package/service/test/security.test.ts +2 -0
- package/service/test/service.test.ts +94 -1
|
@@ -74,11 +74,10 @@ export async function registerPackedVehicle(pi: ExtensionAPI): Promise<void> {
|
|
|
74
74
|
await registerVehicleTools(pi, client, {
|
|
75
75
|
permissions: ["packed:read"],
|
|
76
76
|
principal: { id: "pi-packed" },
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
80
|
-
|
|
81
|
-
shell: { coreOperations: CORE_OPERATIONS, broker: { ownVehicleName: "pi-packed" } },
|
|
77
|
+
// tools_list/tools_man are a neutral, process-wide singleton now (vehicle-client-pi's own
|
|
78
|
+
// ensureVehicleShellHandle) -- no ownVehicleName/broker option needed here anymore; every
|
|
79
|
+
// vehicle in the process (including packed's own) is discovered and namespaced uniformly.
|
|
80
|
+
shell: { coreOperations: CORE_OPERATIONS },
|
|
82
81
|
});
|
|
83
82
|
} catch {
|
|
84
83
|
// Daemon state is stale/unreachable -- degrade silently, matching
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@danypops/pi-packed",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.24.0",
|
|
4
4
|
"description": "Pi package lifecycle, validation, daemon, tools, profiles, and TUI",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
"@danypops/packed": "^0.7.0",
|
|
32
32
|
"@danypops/pi-extension-harness": "^0.7.0",
|
|
33
33
|
"@danypops/vehicle-client": "^0.8.1",
|
|
34
|
-
"@danypops/vehicle-client-pi": "^0.
|
|
34
|
+
"@danypops/vehicle-client-pi": "^0.40.1",
|
|
35
35
|
"@danypops/vehicle-core": "^0.15.0",
|
|
36
36
|
"@danypops/vehicle-server": "^0.22.0",
|
|
37
37
|
"jiti": "^2.7.0",
|
package/service/src/cli/cli.ts
CHANGED
|
@@ -171,6 +171,7 @@ interface Flags {
|
|
|
171
171
|
machineLocal: boolean;
|
|
172
172
|
ecosystem: boolean;
|
|
173
173
|
self: boolean;
|
|
174
|
+
all: boolean;
|
|
174
175
|
project?: string;
|
|
175
176
|
version?: string;
|
|
176
177
|
to?: string;
|
|
@@ -189,6 +190,7 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
|
189
190
|
machineLocal: false,
|
|
190
191
|
ecosystem: false,
|
|
191
192
|
self: false,
|
|
193
|
+
all: false,
|
|
192
194
|
};
|
|
193
195
|
const pos: string[] = [];
|
|
194
196
|
for (let i = 0; i < rest.length; i++) {
|
|
@@ -203,6 +205,7 @@ function parseFlags(rest: string[]): { flags: Flags; pos: string[] } {
|
|
|
203
205
|
else if (a === "--machine-local") flags.machineLocal = true;
|
|
204
206
|
else if (a === "--ecosystem") flags.ecosystem = true;
|
|
205
207
|
else if (a === "--self") flags.self = true;
|
|
208
|
+
else if (a === "--all") flags.all = true;
|
|
206
209
|
else if (a === "--limit" && i + 1 < rest.length) flags.limit = Number(rest[++i]) || SEARCH_DEFAULT_LIMIT;
|
|
207
210
|
else if (a.startsWith("--limit=")) flags.limit = Number(a.slice(8)) || SEARCH_DEFAULT_LIMIT;
|
|
208
211
|
else if (a === "--project" && i + 1 < rest.length) flags.project = rest[++i];
|
|
@@ -671,8 +674,29 @@ const commands: Record<string, { usage: string; run: Command }> = {
|
|
|
671
674
|
|
|
672
675
|
update: {
|
|
673
676
|
usage:
|
|
674
|
-
"packed update <configured-source> [--to <new-source>] [--approve] [--json] | packed update --self [--approve] [--json] (--to replaces an exact pin end to end -- see verify-deploy/doctor for post-replace checks)",
|
|
677
|
+
"packed update <configured-source> [--to <new-source>] [--approve] [--json] | packed update --self [--approve] [--json] | packed update --all [--approve] [--json] (--to replaces an exact pin end to end -- see verify-deploy/doctor for post-replace checks; --all batches every currently-stale package, re-resolving once instead of once per package)",
|
|
675
678
|
async run(_rest, d, flags, pos) {
|
|
679
|
+
if (flags.all) {
|
|
680
|
+
if (!d.daemon) return fail("update --all requires a running packed daemon\n");
|
|
681
|
+
try {
|
|
682
|
+
const result = await d.daemon.updateAll(undefined, flags.approved);
|
|
683
|
+
if (flags.json) return ok(`${JSON.stringify(result)}\n`);
|
|
684
|
+
if (result.results.length === 0) return ok("all pi packages up to date (per the local mirror)\n");
|
|
685
|
+
let out = `${result.output}\n\n`;
|
|
686
|
+
for (const item of result.results) {
|
|
687
|
+
const transition = item.previousVersion && item.currentVersion ? ` (${item.previousVersion} → ${item.currentVersion})` : "";
|
|
688
|
+
const status = item.ok ? (item.alreadyUpToDate ? "already up to date" : `updated${transition}`) : `FAILED: ${item.output}`;
|
|
689
|
+
out += ` ${item.source} ${status}\n`;
|
|
690
|
+
}
|
|
691
|
+
if (result.reresolveError) out += `\nWARNING: final dependency re-resolution failed: ${result.reresolveError}\n`;
|
|
692
|
+
const anyChanged = result.results.some((item) => item.ok && !item.alreadyUpToDate);
|
|
693
|
+
if (anyChanged) out += "\nReload Pi with /reload to activate the updated packages.\n";
|
|
694
|
+
return result.ok ? ok(out) : fail(out);
|
|
695
|
+
} catch (error) {
|
|
696
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
697
|
+
return flags.json ? fail(`${JSON.stringify({ ok: false, error: message })}\n`) : fail(`${message}\n`);
|
|
698
|
+
}
|
|
699
|
+
}
|
|
676
700
|
if (flags.self) {
|
|
677
701
|
try {
|
|
678
702
|
assertPackagePermission(await d.security.security(), "update.self", flags.approved);
|
|
@@ -52,6 +52,7 @@ export interface PackageDaemonPort {
|
|
|
52
52
|
reconcileServices(approved?: boolean, projectRoot?: string): Promise<OperationOutputs["package.reconcile_services"]>;
|
|
53
53
|
remove(name: string, approved?: boolean): Promise<string>;
|
|
54
54
|
update(source: string, approved?: boolean, target?: string): Promise<UpdateOutcome>;
|
|
55
|
+
updateAll(sources?: string[], approved?: boolean): Promise<OperationOutputs["package.update_all"]>;
|
|
55
56
|
piStatus(): Promise<PiVersionReport>;
|
|
56
57
|
resourcesList(projectRoot?: string): Promise<{ global: PackageResources[]; project: PackageResources[] }>;
|
|
57
58
|
resourcesToggle(
|
|
@@ -283,6 +284,14 @@ export class PackageDaemonClient implements PackageDaemonPort {
|
|
|
283
284
|
rollback: result.rollback,
|
|
284
285
|
};
|
|
285
286
|
}
|
|
287
|
+
|
|
288
|
+
/** Batch update -- see updateManyPackages()'s own doc comment (install.ts). sources omitted
|
|
289
|
+
* defaults to every currently-stale global package the mirror already knows about. */
|
|
290
|
+
async updateAll(sources?: string[], approved = false): Promise<OperationOutputs["package.update_all"]> {
|
|
291
|
+
const result = await this.call("package.update_all", { sources, approved });
|
|
292
|
+
if (!result.ok) throw new PackageDaemonError(result.output || "failed to update packages", "package.update_all");
|
|
293
|
+
return result;
|
|
294
|
+
}
|
|
286
295
|
}
|
|
287
296
|
|
|
288
297
|
export class PackageDaemonInstaller implements Installer {
|
|
@@ -18,6 +18,7 @@ import { NpmPackVerifier, type PackReport } from "../adoption/pack.ts";
|
|
|
18
18
|
import { type AdoptionReport, scoreTarget } from "../adoption/score.ts";
|
|
19
19
|
import { buildIndex, indexPath, type PackageIndex, readIndex, writeIndex } from "../index/build-index.ts";
|
|
20
20
|
import { syncCatalog } from "../packages/catalog.ts";
|
|
21
|
+
import { updateManyPackages } from "../packages/install.ts";
|
|
21
22
|
import { catalogList, dbPath, getSyncMeta, latestVersion, openDb, searchLocal } from "../packages/db.ts";
|
|
22
23
|
import { defaultPiHome, npmPackageName, readInstalledPackagesAcrossScopes, splitNpmSource } from "../packages/installed.ts";
|
|
23
24
|
import type { InstalledPkg, Installer, Pkg, PkgInfo, Registry, SearchPage, UpdateOutcome, UpdatesSnapshot } from "../packages/package.ts";
|
|
@@ -113,6 +114,7 @@ export type OperationName =
|
|
|
113
114
|
| "package.reconcile_services"
|
|
114
115
|
| "package.remove"
|
|
115
116
|
| "package.update"
|
|
117
|
+
| "package.update_all"
|
|
116
118
|
| "resources.list"
|
|
117
119
|
| "resources.toggle"
|
|
118
120
|
| "pi.status"
|
|
@@ -144,6 +146,7 @@ export interface OperationInputs {
|
|
|
144
146
|
"package.reconcile_services": { approved?: boolean; projectRoot?: string };
|
|
145
147
|
"package.remove": { name: string; approved?: boolean };
|
|
146
148
|
"package.update": { source: string; approved?: boolean; target?: string };
|
|
149
|
+
"package.update_all": { sources?: string[]; approved?: boolean };
|
|
147
150
|
"resources.list": { projectRoot?: string };
|
|
148
151
|
"resources.toggle": { source: string; field: ResourceField; path: string; enabled: boolean; projectRoot?: string; approved?: boolean };
|
|
149
152
|
"pi.status": Record<string, never>;
|
|
@@ -157,6 +160,10 @@ interface MutationResponse {
|
|
|
157
160
|
output: string;
|
|
158
161
|
}
|
|
159
162
|
interface UpdateMutationResponse extends MutationResponse, Partial<Omit<UpdateOutcome, "output">> {}
|
|
163
|
+
interface UpdateAllMutationResponse extends MutationResponse {
|
|
164
|
+
results: Array<{ source: string; ok: boolean; output: string; serviceReconciled?: boolean } & Partial<Omit<UpdateOutcome, "output">>>;
|
|
165
|
+
reresolveError?: string;
|
|
166
|
+
}
|
|
160
167
|
interface InstallServiceResponse {
|
|
161
168
|
ok: boolean;
|
|
162
169
|
output: string;
|
|
@@ -192,6 +199,7 @@ export interface OperationOutputs {
|
|
|
192
199
|
"package.reconcile_services": ReconcileServicesResponse;
|
|
193
200
|
"package.remove": MutationResponse;
|
|
194
201
|
"package.update": UpdateMutationResponse;
|
|
202
|
+
"package.update_all": UpdateAllMutationResponse;
|
|
195
203
|
"resources.list": { global: PackageResources[]; project: PackageResources[] };
|
|
196
204
|
"resources.toggle": MutationResponse;
|
|
197
205
|
"pi.status": PiVersionReport;
|
|
@@ -224,6 +232,7 @@ export const OPERATION_NAMES: readonly OperationName[] = [
|
|
|
224
232
|
"package.reconcile_services",
|
|
225
233
|
"package.remove",
|
|
226
234
|
"package.update",
|
|
235
|
+
"package.update_all",
|
|
227
236
|
"resources.list",
|
|
228
237
|
"resources.toggle",
|
|
229
238
|
"pi.status",
|
|
@@ -577,6 +586,55 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
577
586
|
}
|
|
578
587
|
}
|
|
579
588
|
|
|
589
|
+
if (path === "/update-all" && req.method === "POST") {
|
|
590
|
+
let approved = false;
|
|
591
|
+
let sources: string[] | undefined;
|
|
592
|
+
try {
|
|
593
|
+
const body = (await req.json()) as { approved?: unknown; sources?: unknown };
|
|
594
|
+
approved = body.approved === true;
|
|
595
|
+
if (Array.isArray(body.sources)) sources = body.sources.filter((item): item is string => typeof item === "string");
|
|
596
|
+
} catch {
|
|
597
|
+
/* fall through -- approved stays false, sources stays undefined (defaults to every stale package) */
|
|
598
|
+
}
|
|
599
|
+
if (sources && sources.some((source) => !SOURCE_RE.test(source))) {
|
|
600
|
+
return err(400, "invalid source; want a configured npm:, git:, or https package source");
|
|
601
|
+
}
|
|
602
|
+
const denied = authorize("update_all", approved);
|
|
603
|
+
if (denied) return denied;
|
|
604
|
+
// Default: every currently-stale GLOBAL package the mirror already knows about -- same set
|
|
605
|
+
// `packed updates` (no --project) reports. A caller wanting project-scoped sources too
|
|
606
|
+
// passes them explicitly via body.sources; this endpoint never guesses project scope.
|
|
607
|
+
if (!sources) {
|
|
608
|
+
const snap = await loadUpdates(deps.stateDir);
|
|
609
|
+
sources = (snap?.updates ?? []).map((entry) => `npm:${entry.name}`);
|
|
610
|
+
}
|
|
611
|
+
// Daemon-dependency sources (see classifyUpdateSource's own doc comment) still route through
|
|
612
|
+
// updateOnly()/update() here, exactly like a bare `pi update --extension` would today -- a
|
|
613
|
+
// known, non-regressive scope limit for this batch endpoint's first version, not a silent
|
|
614
|
+
// misclassification: each such source simply reports its already-existing, honest
|
|
615
|
+
// "No matching package found" failure as its own independent per-source outcome.
|
|
616
|
+
const batchResult = await updateManyPackages(deps.inst, sources, { approved });
|
|
617
|
+
const results: UpdateAllMutationResponse["results"] = batchResult.outcomes.map((item) =>
|
|
618
|
+
item.status === "succeeded" && item.outcome
|
|
619
|
+
? { source: item.source, ok: true, ...item.outcome }
|
|
620
|
+
: { source: item.source, ok: false, output: item.error ?? "update failed" },
|
|
621
|
+
);
|
|
622
|
+
// Best-effort per-source service-restart reconciliation, same as /update's own -- one
|
|
623
|
+
// package's restart failing never fails the batch or blocks a sibling's own reconciliation.
|
|
624
|
+
for (const result of results) {
|
|
625
|
+
if (!result.ok || result.alreadyUpToDate || !result.source.startsWith("npm:")) continue;
|
|
626
|
+
try {
|
|
627
|
+
const service = await daemonServiceInstaller.restart(piHomeForServiceInstall, result.source);
|
|
628
|
+
if (service.ok) result.serviceReconciled = service.restarted;
|
|
629
|
+
} catch {
|
|
630
|
+
/* best-effort -- a restart failure never fails the batch */
|
|
631
|
+
}
|
|
632
|
+
}
|
|
633
|
+
const ok = results.every((result) => result.ok) && !batchResult.reresolveError;
|
|
634
|
+
const output = `updated ${results.filter((result) => result.ok && !result.alreadyUpToDate).length}/${results.length} package(s)`;
|
|
635
|
+
return json({ ok, output, results, ...(batchResult.reresolveError ? { reresolveError: batchResult.reresolveError } : {}) });
|
|
636
|
+
}
|
|
637
|
+
|
|
580
638
|
if (path === "/updates" && req.method === "GET") {
|
|
581
639
|
const snap = await loadUpdates(deps.stateDir);
|
|
582
640
|
return json(snap ?? { updates: [] });
|
|
@@ -762,6 +820,10 @@ export function createApp(deps: Deps): { fetch: (req: Request) => Promise<Respon
|
|
|
762
820
|
path = "/update";
|
|
763
821
|
init = { method: "POST", body: JSON.stringify(input) };
|
|
764
822
|
break;
|
|
823
|
+
case "package.update_all":
|
|
824
|
+
path = "/update-all";
|
|
825
|
+
init = { method: "POST", body: JSON.stringify(input) };
|
|
826
|
+
break;
|
|
765
827
|
default:
|
|
766
828
|
throw new PackageOperationError(`unknown operation: ${String(op)}`, 404);
|
|
767
829
|
}
|
|
@@ -170,6 +170,11 @@ const OPERATION_META: Record<OperationName, OperationMeta> = {
|
|
|
170
170
|
effect: "open-world",
|
|
171
171
|
requiresApproval: true,
|
|
172
172
|
},
|
|
173
|
+
"package.update_all": {
|
|
174
|
+
description: "Updates every currently-stale Pi package in one batch, re-resolving the dependency tree once instead of once per package.",
|
|
175
|
+
effect: "open-world",
|
|
176
|
+
requiresApproval: true,
|
|
177
|
+
},
|
|
173
178
|
"resources.list": {
|
|
174
179
|
description: "Lists global and project-scoped Pi resources (extensions, skills, prompts, themes).",
|
|
175
180
|
effect: "read",
|
|
@@ -151,23 +151,22 @@ export class ExecInstaller implements Installer {
|
|
|
151
151
|
return this.run(["remove", ...(options?.local ? ["-l"] : []), source]);
|
|
152
152
|
}
|
|
153
153
|
|
|
154
|
-
|
|
154
|
+
/**
|
|
155
|
+
* The real `pi update --extension <source>` mutation alone -- no trailing reresolveDependencyTree()
|
|
156
|
+
* (a batch caller defers that to once per batch via updateManyPackages(), not once per package).
|
|
157
|
+
* Returns the full UpdateOutcome (unlike installOnly()'s raw string): a single package's own
|
|
158
|
+
* before/after version diff is already correct right after ITS OWN update -- reresolveDependencyTree()
|
|
159
|
+
* exists to fix a DIFFERENT sibling's drift, not this package's own resolved version. MUST be
|
|
160
|
+
* awaited sequentially across a batch, never run concurrently -- same shared settings.json/
|
|
161
|
+
* package.json mutation hazard as installOnly(), see its own doc comment.
|
|
162
|
+
*/
|
|
163
|
+
async updateOnly(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
|
|
155
164
|
if (options?.target) return this.replace(source, options.target, options);
|
|
156
165
|
const t0 = performance.now();
|
|
157
166
|
const pinned = isPinnedNpmSource(source);
|
|
158
167
|
const previousVersion = readResolvedVersion(this.piHome, source);
|
|
159
|
-
const t1 = performance.now();
|
|
160
168
|
const output = await this.run(["update", "--extension", source]);
|
|
161
|
-
|
|
162
|
-
const t2 = performance.now();
|
|
163
|
-
await this.reresolveDependencyTree();
|
|
164
|
-
const reresolveMs = performance.now() - t2;
|
|
165
|
-
this.logger.debug("update timing", {
|
|
166
|
-
source,
|
|
167
|
-
updateMs: round1(updateMs),
|
|
168
|
-
reresolveMs: round1(reresolveMs),
|
|
169
|
-
totalMs: round1(performance.now() - t0),
|
|
170
|
-
});
|
|
169
|
+
this.logger.debug("updateOnly timing", { source, updateMs: round1(performance.now() - t0) });
|
|
171
170
|
const currentVersion = readResolvedVersion(this.piHome, source);
|
|
172
171
|
// Only trust a "nothing changed" conclusion when we actually read a
|
|
173
172
|
// real version both before and after (npm source, resolvable in
|
|
@@ -202,6 +201,24 @@ export class ExecInstaller implements Installer {
|
|
|
202
201
|
};
|
|
203
202
|
}
|
|
204
203
|
|
|
204
|
+
async update(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome> {
|
|
205
|
+
if (options?.target) return this.replace(source, options.target, options);
|
|
206
|
+
const t0 = performance.now();
|
|
207
|
+
const t1 = performance.now();
|
|
208
|
+
const result = await this.updateOnly(source, options);
|
|
209
|
+
const updateMs = performance.now() - t1;
|
|
210
|
+
const t2 = performance.now();
|
|
211
|
+
await this.reresolveDependencyTree();
|
|
212
|
+
const reresolveMs = performance.now() - t2;
|
|
213
|
+
this.logger.debug("update timing", {
|
|
214
|
+
source,
|
|
215
|
+
updateMs: round1(updateMs),
|
|
216
|
+
reresolveMs: round1(reresolveMs),
|
|
217
|
+
totalMs: round1(performance.now() - t0),
|
|
218
|
+
});
|
|
219
|
+
return result;
|
|
220
|
+
}
|
|
221
|
+
|
|
205
222
|
/**
|
|
206
223
|
* The alternate mutation path for a source classifyUpdateSource() flags as
|
|
207
224
|
* "daemon-dependency" -- genuinely installed and npm-resolvable, but NOT
|
|
@@ -325,3 +342,57 @@ export class ExecInstaller implements Installer {
|
|
|
325
342
|
};
|
|
326
343
|
}
|
|
327
344
|
}
|
|
345
|
+
|
|
346
|
+
export interface UpdateManyOutcome {
|
|
347
|
+
readonly source: string;
|
|
348
|
+
readonly status: "succeeded" | "failed";
|
|
349
|
+
readonly outcome?: UpdateOutcome;
|
|
350
|
+
readonly error?: string;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
export interface UpdateManyResult {
|
|
354
|
+
readonly outcomes: readonly UpdateManyOutcome[];
|
|
355
|
+
/** True whenever any succeeded source actually changed version -- one combined signal instead of a caller re-deriving it per outcome. */
|
|
356
|
+
readonly reloadRequired: boolean;
|
|
357
|
+
/** Set only when the final batch-wide reresolve itself failed -- every individual outcome above already reflects its own real result regardless. */
|
|
358
|
+
readonly reresolveError?: string;
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Batch update over several sources at once -- same validate-concurrently/commit-sequentially/
|
|
363
|
+
* reresolve-once shape as SetupManager.apply()'s own batch mode (see Installer.updateOnly's own
|
|
364
|
+
* doc comment): every source is updated in turn via updateOnly() (no per-source reresolve), then
|
|
365
|
+
* reresolveDependencyTree() runs exactly ONCE for the whole batch -- the real cost `packed update`
|
|
366
|
+
* one source at a time was paying N times over (see ExecInstaller's own constructor doc comment).
|
|
367
|
+
* Sequential across sources, never fanned out -- same shared-file mutation hazard as installOnly().
|
|
368
|
+
* One source failing never stops the rest; each gets its own independent outcome. Falls back to
|
|
369
|
+
* one update() call per source (each paying its own reresolve) when the installer doesn't
|
|
370
|
+
* implement the updateOnly/reresolveDependencyTree batch trio -- matching SetupManager.apply()'s
|
|
371
|
+
* own graceful degradation for a non-batch-capable Installer (e.g. a plain test double).
|
|
372
|
+
*/
|
|
373
|
+
export async function updateManyPackages(
|
|
374
|
+
installer: Installer,
|
|
375
|
+
sources: readonly string[],
|
|
376
|
+
options?: { approved?: boolean },
|
|
377
|
+
): Promise<UpdateManyResult> {
|
|
378
|
+
const batch = Boolean(installer.updateOnly && installer.reresolveDependencyTree);
|
|
379
|
+
const outcomes: UpdateManyOutcome[] = [];
|
|
380
|
+
for (const source of sources) {
|
|
381
|
+
try {
|
|
382
|
+
const outcome =
|
|
383
|
+
batch && installer.updateOnly ? await installer.updateOnly(source, options) : await installer.update(source, options);
|
|
384
|
+
outcomes.push({ source, status: "succeeded", outcome });
|
|
385
|
+
} catch (error) {
|
|
386
|
+
outcomes.push({ source, status: "failed", error: error instanceof Error ? error.message : String(error) });
|
|
387
|
+
}
|
|
388
|
+
}
|
|
389
|
+
const reloadRequired = outcomes.some((item) => item.status === "succeeded" && item.outcome?.reloadRequired);
|
|
390
|
+
if (batch && installer.reresolveDependencyTree && outcomes.some((item) => item.status === "succeeded")) {
|
|
391
|
+
try {
|
|
392
|
+
await installer.reresolveDependencyTree();
|
|
393
|
+
} catch (error) {
|
|
394
|
+
return { outcomes, reloadRequired, reresolveError: error instanceof Error ? error.message : String(error) };
|
|
395
|
+
}
|
|
396
|
+
}
|
|
397
|
+
return { outcomes, reloadRequired };
|
|
398
|
+
}
|
|
@@ -219,6 +219,15 @@ export interface Installer {
|
|
|
219
219
|
*/
|
|
220
220
|
validate?(source: string): Promise<InstallValidationResult>;
|
|
221
221
|
installOnly?(source: string, options?: { approved?: boolean; local?: boolean }): Promise<string>;
|
|
222
|
+
/**
|
|
223
|
+
* updateOnly() is update()'s own equivalent split: the real `pi update --extension` mutation
|
|
224
|
+
* (or replace(), when options.target is given) WITHOUT the trailing reresolveDependencyTree(),
|
|
225
|
+
* returning the same UpdateOutcome update() itself returns -- a single package's own before/
|
|
226
|
+
* after version diff is already correct right after its own mutation, reresolveDependencyTree()
|
|
227
|
+
* exists to fix a DIFFERENT sibling's drift. See updateManyPackages() (install.ts) for the
|
|
228
|
+
* batch caller. Same SEQUENTIAL-only constraint as installOnly() above.
|
|
229
|
+
*/
|
|
230
|
+
updateOnly?(source: string, options?: { approved?: boolean; local?: boolean; target?: string }): Promise<UpdateOutcome>;
|
|
222
231
|
reresolveDependencyTree?(): Promise<string>;
|
|
223
232
|
}
|
|
224
233
|
|
|
@@ -26,6 +26,7 @@ export const PACKAGE_OPERATIONS = [
|
|
|
26
26
|
"reconcile_services",
|
|
27
27
|
"setup.apply",
|
|
28
28
|
"update",
|
|
29
|
+
"update_all",
|
|
29
30
|
"update.self",
|
|
30
31
|
"remove",
|
|
31
32
|
"resources.list",
|
|
@@ -74,6 +75,7 @@ const CLASSIFICATIONS: Record<PackageOperation, PackageOperationClassification>
|
|
|
74
75
|
reconcile_services: "code-execution",
|
|
75
76
|
"setup.apply": "code-execution",
|
|
76
77
|
update: "code-execution",
|
|
78
|
+
update_all: "code-execution",
|
|
77
79
|
"update.self": "code-execution",
|
|
78
80
|
remove: "settings-mutation",
|
|
79
81
|
"resources.list": "read",
|
package/service/test/cli.test.ts
CHANGED
|
@@ -788,6 +788,71 @@ describe("CLI", () => {
|
|
|
788
788
|
});
|
|
789
789
|
});
|
|
790
790
|
|
|
791
|
+
/** A PackageDaemonPort stub that throws "unexpected call" for anything the test doesn't
|
|
792
|
+
* explicitly override -- update --all only ever calls updateAll(), so nothing else should
|
|
793
|
+
* ever be invoked; a Proxy avoids hand-writing 25+ unused method stubs. */
|
|
794
|
+
function baseDaemonPort(): PackageDaemonPort {
|
|
795
|
+
return new Proxy(
|
|
796
|
+
{},
|
|
797
|
+
{
|
|
798
|
+
get: (_target, prop) => async () => {
|
|
799
|
+
throw new Error(`unexpected call: ${String(prop)}`);
|
|
800
|
+
},
|
|
801
|
+
},
|
|
802
|
+
) as PackageDaemonPort;
|
|
803
|
+
}
|
|
804
|
+
|
|
805
|
+
it("update --all fails closed without a running daemon", async () => {
|
|
806
|
+
const d = deps({ daemon: undefined });
|
|
807
|
+
const { code, out } = await cliRun(["update", "--all", "--approve"], d);
|
|
808
|
+
expect(code).toBe(1);
|
|
809
|
+
expect(out).toContain("requires a running packed daemon");
|
|
810
|
+
});
|
|
811
|
+
|
|
812
|
+
it("update --all delegates to the daemon's batch endpoint and reports a per-package summary", async () => {
|
|
813
|
+
const calls: Array<[string[] | undefined, boolean | undefined]> = [];
|
|
814
|
+
const daemon = {
|
|
815
|
+
...baseDaemonPort(),
|
|
816
|
+
async updateAll(sources?: string[], approved?: boolean) {
|
|
817
|
+
calls.push([sources, approved]);
|
|
818
|
+
return {
|
|
819
|
+
ok: true,
|
|
820
|
+
output: "updated 1/2 package(s)",
|
|
821
|
+
results: [
|
|
822
|
+
{ source: "npm:pi-lsp", ok: true, output: "Updated npm:pi-lsp", previousVersion: "1.0.0", currentVersion: "1.1.0" },
|
|
823
|
+
{ source: "npm:pi-tickets", ok: true, output: "Updated npm:pi-tickets", alreadyUpToDate: true },
|
|
824
|
+
],
|
|
825
|
+
};
|
|
826
|
+
},
|
|
827
|
+
};
|
|
828
|
+
const d = deps({ daemon });
|
|
829
|
+
const { code, out } = await cliRun(["update", "--all", "--approve"], d);
|
|
830
|
+
expect(code).toBe(0);
|
|
831
|
+
expect(calls).toEqual([[undefined, true]]);
|
|
832
|
+
expect(out).toContain("npm:pi-lsp updated (1.0.0 \u2192 1.1.0)");
|
|
833
|
+
expect(out).toContain("npm:pi-tickets already up to date");
|
|
834
|
+
expect(out).toContain("Reload Pi with /reload to activate the updated packages.");
|
|
835
|
+
const json = await cliRun(["update", "--all", "--approve", "--json"], d);
|
|
836
|
+
expect(JSON.parse(json.out).results).toHaveLength(2);
|
|
837
|
+
});
|
|
838
|
+
|
|
839
|
+
it("update --all reports failures inline with exit code 1, without hiding the packages that did succeed", async () => {
|
|
840
|
+
const daemon = {
|
|
841
|
+
...baseDaemonPort(),
|
|
842
|
+
async updateAll() {
|
|
843
|
+
return {
|
|
844
|
+
ok: false,
|
|
845
|
+
output: "updated 0/1 package(s)",
|
|
846
|
+
results: [{ source: "npm:broken", ok: false, output: "No matching package found" }],
|
|
847
|
+
};
|
|
848
|
+
},
|
|
849
|
+
};
|
|
850
|
+
const d = deps({ daemon });
|
|
851
|
+
const { code, out } = await cliRun(["update", "--all", "--approve"], d);
|
|
852
|
+
expect(code).toBe(1);
|
|
853
|
+
expect(out).toContain("npm:broken FAILED: No matching package found");
|
|
854
|
+
});
|
|
855
|
+
|
|
791
856
|
it("remove wants a bare name and has stable JSON output", async () => {
|
|
792
857
|
const d = deps();
|
|
793
858
|
expect((await cliRun(["remove", "npm:foo"], d)).code).toBe(2);
|
|
@@ -947,6 +1012,10 @@ describe("CLI", () => {
|
|
|
947
1012
|
async update(source) {
|
|
948
1013
|
return { output: source, reloadRequired: false, alreadyUpToDate: true, pinned: false };
|
|
949
1014
|
},
|
|
1015
|
+
async updateAll(sources) {
|
|
1016
|
+
calls.push(`updateAll:${sources?.join(",")}`);
|
|
1017
|
+
return { ok: true, output: "updated 0/0 package(s)", results: [] };
|
|
1018
|
+
},
|
|
950
1019
|
async piStatus() {
|
|
951
1020
|
calls.push("piStatus");
|
|
952
1021
|
return { current: "0.82.1", latest: "0.83.0", upToDate: false };
|
|
@@ -11,10 +11,11 @@
|
|
|
11
11
|
* version diff is what actually drives reloadRequired/alreadyUpToDate.
|
|
12
12
|
*/
|
|
13
13
|
import { afterEach, describe, expect, it } from "bun:test";
|
|
14
|
-
import { chmodSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
14
|
+
import { chmodSync, existsSync, mkdirSync, mkdtempSync, readFileSync, rmSync, writeFileSync } from "node:fs";
|
|
15
15
|
import { tmpdir } from "node:os";
|
|
16
16
|
import { join } from "node:path";
|
|
17
|
-
import { ExecInstaller } from "../src/packages/install.ts";
|
|
17
|
+
import { ExecInstaller, updateManyPackages } from "../src/packages/install.ts";
|
|
18
|
+
import type { Installer, UpdateOutcome } from "../src/packages/package.ts";
|
|
18
19
|
import { createLogger } from "../src/shared/log.ts";
|
|
19
20
|
|
|
20
21
|
/**
|
|
@@ -710,3 +711,98 @@ describe("ExecInstaller — run()/reresolveDependencyTree() thread the CURRENT p
|
|
|
710
711
|
expect(readFileSync(npmLog, "utf8")).toBe("reresolve-sees-this-too");
|
|
711
712
|
});
|
|
712
713
|
});
|
|
714
|
+
|
|
715
|
+
describe("updateManyPackages -- batch update over several sources at once, reresolving ONCE for the whole batch", () => {
|
|
716
|
+
it("updates every source via updateOnly() and runs exactly one npm install afterward, not one per source", async () => {
|
|
717
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
718
|
+
const piHome = writePiHome({ "@scope/a": "1.0.0", "@scope/b": "1.0.0" });
|
|
719
|
+
const bin = writeFakePi(scriptDir);
|
|
720
|
+
const npmLog = join(scriptDir, "npm.log");
|
|
721
|
+
const npmBin = writeFakeNpm(scriptDir, npmLog);
|
|
722
|
+
const installer = new ExecInstaller(bin, piHome, undefined, npmBin);
|
|
723
|
+
|
|
724
|
+
const result = await updateManyPackages(installer, ["npm:@scope/a", "npm:@scope/b"]);
|
|
725
|
+
|
|
726
|
+
expect(result.outcomes.map((o) => o.source)).toEqual(["npm:@scope/a", "npm:@scope/b"]);
|
|
727
|
+
expect(result.outcomes.every((o) => o.status === "succeeded")).toBe(true);
|
|
728
|
+
// The real point of this batch path: ONE npm install for two sources, not two.
|
|
729
|
+
const invocations = readFileSync(npmLog, "utf8").trim().split("\n").filter((line) => line === "install");
|
|
730
|
+
expect(invocations).toHaveLength(1);
|
|
731
|
+
});
|
|
732
|
+
|
|
733
|
+
it("one source failing never stops the rest, and each gets its own independent outcome", async () => {
|
|
734
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
735
|
+
const piHome = writePiHome({ "@scope/a": "1.0.0", "@scope/b": "1.0.0" });
|
|
736
|
+
const failingPi = join(scriptDir, "fake-pi-fail-once");
|
|
737
|
+
writeFileSync(
|
|
738
|
+
failingPi,
|
|
739
|
+
[
|
|
740
|
+
"#!/usr/bin/env bash",
|
|
741
|
+
"set -euo pipefail",
|
|
742
|
+
'source="${3:-}"',
|
|
743
|
+
'if [ "$source" = "npm:@scope/a" ]; then echo "No matching package found" >&2; exit 1; fi',
|
|
744
|
+
'echo "Updated $source"',
|
|
745
|
+
"exit 0",
|
|
746
|
+
].join("\n"),
|
|
747
|
+
);
|
|
748
|
+
chmodSync(failingPi, 0o755);
|
|
749
|
+
const npmLog = join(scriptDir, "npm.log");
|
|
750
|
+
const npmBin = writeFakeNpm(scriptDir, npmLog);
|
|
751
|
+
const installer = new ExecInstaller(failingPi, piHome, undefined, npmBin);
|
|
752
|
+
|
|
753
|
+
const result = await updateManyPackages(installer, ["npm:@scope/a", "npm:@scope/b"]);
|
|
754
|
+
|
|
755
|
+
expect(result.outcomes[0]).toMatchObject({ source: "npm:@scope/a", status: "failed" });
|
|
756
|
+
expect(result.outcomes[1]).toMatchObject({ source: "npm:@scope/b", status: "succeeded" });
|
|
757
|
+
// The batch-wide reresolve still runs once, since @scope/b did succeed.
|
|
758
|
+
expect(readFileSync(npmLog, "utf8").trim().split("\n").filter((line) => line === "install")).toHaveLength(1);
|
|
759
|
+
});
|
|
760
|
+
|
|
761
|
+
it("never runs the batch-wide reresolve when every source failed", async () => {
|
|
762
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
763
|
+
const piHome = writePiHome({ "@scope/a": "1.0.0" });
|
|
764
|
+
const failingPi = join(scriptDir, "fake-pi-always-fail");
|
|
765
|
+
writeFileSync(failingPi, ["#!/usr/bin/env bash", "echo 'No matching package found' >&2", "exit 1"].join("\n"));
|
|
766
|
+
chmodSync(failingPi, 0o755);
|
|
767
|
+
const npmLog = join(scriptDir, "npm.log");
|
|
768
|
+
const npmBin = writeFakeNpm(scriptDir, npmLog);
|
|
769
|
+
const installer = new ExecInstaller(failingPi, piHome, undefined, npmBin);
|
|
770
|
+
|
|
771
|
+
await updateManyPackages(installer, ["npm:@scope/a"]);
|
|
772
|
+
|
|
773
|
+
expect(existsSync(npmLog)).toBe(false);
|
|
774
|
+
});
|
|
775
|
+
|
|
776
|
+
it("reports a failed final reresolve distinctly, after every individual source already succeeded", async () => {
|
|
777
|
+
const scriptDir = track(mkdtempSync(join(tmpdir(), "packed-exec-bin-")));
|
|
778
|
+
const piHome = writePiHome({ "@scope/a": "1.0.0" });
|
|
779
|
+
const bin = writeFakePi(scriptDir);
|
|
780
|
+
const failingNpm = join(scriptDir, "fake-npm-fail");
|
|
781
|
+
writeFileSync(failingNpm, ["#!/usr/bin/env bash", "echo 'ERESOLVE unable to resolve dependency tree' >&2", "exit 1"].join("\n"));
|
|
782
|
+
chmodSync(failingNpm, 0o755);
|
|
783
|
+
const installer = new ExecInstaller(bin, piHome, undefined, failingNpm);
|
|
784
|
+
|
|
785
|
+
const result = await updateManyPackages(installer, ["npm:@scope/a"]);
|
|
786
|
+
|
|
787
|
+
expect(result.outcomes[0]?.status).toBe("succeeded");
|
|
788
|
+
expect(result.reresolveError).toMatch(/npm install failed to re-resolve/);
|
|
789
|
+
});
|
|
790
|
+
|
|
791
|
+
it("falls back to one update() call per source (each paying its own reresolve) when the installer lacks the batch trio", async () => {
|
|
792
|
+
const calls: string[] = [];
|
|
793
|
+
const fake: Installer = {
|
|
794
|
+
install: async () => "",
|
|
795
|
+
remove: async () => "",
|
|
796
|
+
update: async (source): Promise<UpdateOutcome> => {
|
|
797
|
+
calls.push(source);
|
|
798
|
+
return { output: `updated ${source}`, reloadRequired: true, alreadyUpToDate: false, pinned: false };
|
|
799
|
+
},
|
|
800
|
+
};
|
|
801
|
+
|
|
802
|
+
const result = await updateManyPackages(fake, ["npm:a", "npm:b"]);
|
|
803
|
+
|
|
804
|
+
expect(calls).toEqual(["npm:a", "npm:b"]);
|
|
805
|
+
expect(result.outcomes.every((o) => o.status === "succeeded")).toBe(true);
|
|
806
|
+
expect(result.reloadRequired).toBe(true);
|
|
807
|
+
});
|
|
808
|
+
});
|
|
@@ -39,6 +39,7 @@ describe("package permission policy", () => {
|
|
|
39
39
|
"reconcile_services",
|
|
40
40
|
"setup.apply",
|
|
41
41
|
"update",
|
|
42
|
+
"update_all",
|
|
42
43
|
"update.self",
|
|
43
44
|
"remove",
|
|
44
45
|
"resources.list",
|
|
@@ -70,6 +71,7 @@ describe("package permission policy", () => {
|
|
|
70
71
|
restart_service: { classification: "code-execution", approvalRequired: true },
|
|
71
72
|
"setup.apply": { classification: "code-execution", approvalRequired: true },
|
|
72
73
|
update: { classification: "code-execution", approvalRequired: true },
|
|
74
|
+
update_all: { classification: "code-execution", approvalRequired: true },
|
|
73
75
|
"update.self": { classification: "code-execution", approvalRequired: true },
|
|
74
76
|
remove: { classification: "settings-mutation", approvalRequired: true },
|
|
75
77
|
"resources.list": { classification: "read", approvalRequired: false },
|
|
@@ -53,10 +53,20 @@ class FakeInstaller implements Installer {
|
|
|
53
53
|
}
|
|
54
54
|
updateOutcome: Partial<UpdateOutcome> = {};
|
|
55
55
|
gotTarget: string | undefined;
|
|
56
|
+
updatedSources: string[] = [];
|
|
57
|
+
updateOutcomeFor: Record<string, Partial<UpdateOutcome>> = {};
|
|
56
58
|
async update(source: string, options?: { target?: string }): Promise<UpdateOutcome> {
|
|
57
59
|
this.updated = source;
|
|
60
|
+
this.updatedSources.push(source);
|
|
58
61
|
this.gotTarget = options?.target;
|
|
59
|
-
return {
|
|
62
|
+
return {
|
|
63
|
+
output: this.output,
|
|
64
|
+
reloadRequired: true,
|
|
65
|
+
alreadyUpToDate: false,
|
|
66
|
+
pinned: false,
|
|
67
|
+
...this.updateOutcome,
|
|
68
|
+
...this.updateOutcomeFor[source],
|
|
69
|
+
};
|
|
60
70
|
}
|
|
61
71
|
updateDaemonDependencyGotName = "";
|
|
62
72
|
updateDaemonDependencyGotVersion: string | undefined;
|
|
@@ -680,6 +690,89 @@ describe("service app", () => {
|
|
|
680
690
|
expect(inst.updateDaemonDependencyGotName).toBe("");
|
|
681
691
|
});
|
|
682
692
|
|
|
693
|
+
it("POST /update-all validates, authorizes, and delegates every explicitly given source", async () => {
|
|
694
|
+
const inst = new FakeInstaller();
|
|
695
|
+
const app = createApp(deps({ inst }));
|
|
696
|
+
const denied = await app.fetch(
|
|
697
|
+
new Request("http://x/update-all", {
|
|
698
|
+
method: "POST",
|
|
699
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
700
|
+
body: JSON.stringify({ sources: ["npm:pi-lsp"] }),
|
|
701
|
+
}),
|
|
702
|
+
);
|
|
703
|
+
expect(denied.status).toBe(403);
|
|
704
|
+
const allowed = await app.fetch(
|
|
705
|
+
new Request("http://x/update-all", {
|
|
706
|
+
method: "POST",
|
|
707
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
708
|
+
body: JSON.stringify({ sources: ["npm:pi-lsp", "npm:pi-tickets"], approved: true }),
|
|
709
|
+
}),
|
|
710
|
+
);
|
|
711
|
+
expect(allowed.status).toBe(200);
|
|
712
|
+
const body = (await allowed.json()) as any;
|
|
713
|
+
expect(body.ok).toBe(true);
|
|
714
|
+
expect(inst.updatedSources).toEqual(["npm:pi-lsp", "npm:pi-tickets"]);
|
|
715
|
+
expect(body.results.map((r: any) => r.source)).toEqual(["npm:pi-lsp", "npm:pi-tickets"]);
|
|
716
|
+
expect(body.results.every((r: any) => r.ok)).toBe(true);
|
|
717
|
+
});
|
|
718
|
+
|
|
719
|
+
it("POST /update-all defaults to every currently-stale global package from the mirror when sources is omitted", async () => {
|
|
720
|
+
const inst = new FakeInstaller();
|
|
721
|
+
const d = deps({ inst });
|
|
722
|
+
await saveUpdates(d.stateDir, {
|
|
723
|
+
checkedAt: new Date().toISOString(),
|
|
724
|
+
updates: [
|
|
725
|
+
{ name: "pi-lsp", installed: "1.0.0", latest: "1.1.0" },
|
|
726
|
+
{ name: "pi-tickets", installed: "2.0.0", latest: "2.1.0" },
|
|
727
|
+
],
|
|
728
|
+
});
|
|
729
|
+
const app = createApp(d);
|
|
730
|
+
const response = await app.fetch(
|
|
731
|
+
new Request("http://x/update-all", {
|
|
732
|
+
method: "POST",
|
|
733
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
734
|
+
body: JSON.stringify({ approved: true }),
|
|
735
|
+
}),
|
|
736
|
+
);
|
|
737
|
+
expect(response.status).toBe(200);
|
|
738
|
+
expect(inst.updatedSources).toEqual(["npm:pi-lsp", "npm:pi-tickets"]);
|
|
739
|
+
});
|
|
740
|
+
|
|
741
|
+
it("POST /update-all reports one failure without blocking the rest of the batch", async () => {
|
|
742
|
+
const inst = new FakeInstaller();
|
|
743
|
+
inst.updateOutcomeFor["npm:broken"] = undefined as unknown as Partial<UpdateOutcome>;
|
|
744
|
+
const originalUpdate = inst.update.bind(inst);
|
|
745
|
+
inst.update = async (source: string, options?: { target?: string }) => {
|
|
746
|
+
if (source === "npm:broken") throw new Error("No matching package found");
|
|
747
|
+
return originalUpdate(source, options);
|
|
748
|
+
};
|
|
749
|
+
const app = createApp(deps({ inst }));
|
|
750
|
+
const response = await app.fetch(
|
|
751
|
+
new Request("http://x/update-all", {
|
|
752
|
+
method: "POST",
|
|
753
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
754
|
+
body: JSON.stringify({ sources: ["npm:broken", "npm:pi-lsp"], approved: true }),
|
|
755
|
+
}),
|
|
756
|
+
);
|
|
757
|
+
expect(response.status).toBe(200);
|
|
758
|
+
const body = (await response.json()) as any;
|
|
759
|
+
expect(body.ok).toBe(false);
|
|
760
|
+
expect(body.results[0]).toMatchObject({ source: "npm:broken", ok: false });
|
|
761
|
+
expect(body.results[1]).toMatchObject({ source: "npm:pi-lsp", ok: true });
|
|
762
|
+
});
|
|
763
|
+
|
|
764
|
+
it("POST /update-all rejects an invalid source the same way /update does", async () => {
|
|
765
|
+
const app = createApp(deps({ inst: new FakeInstaller() }));
|
|
766
|
+
const response = await app.fetch(
|
|
767
|
+
new Request("http://x/update-all", {
|
|
768
|
+
method: "POST",
|
|
769
|
+
headers: { ...auth, "content-type": "application/json" },
|
|
770
|
+
body: JSON.stringify({ sources: ["not-a-real-source"], approved: true }),
|
|
771
|
+
}),
|
|
772
|
+
);
|
|
773
|
+
expect(response.status).toBe(400);
|
|
774
|
+
});
|
|
775
|
+
|
|
683
776
|
it("GET /updates serves the watcher snapshot", async () => {
|
|
684
777
|
const d = deps();
|
|
685
778
|
await saveUpdates(d.stateDir, {
|