@synap-core/workspace-templates 0.6.3 → 0.6.4

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@synap-core/workspace-templates",
3
- "version": "0.6.3",
3
+ "version": "0.6.4",
4
4
  "type": "module",
5
5
  "packageManager": "pnpm@9.15.4",
6
6
  "description": "Canonical workspace definitions — YAML source + typed exports for browser, backend, CLI, and marketplace",
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { test } from "node:test";
9
9
  import assert from "node:assert/strict";
10
- import { mergeCatalog, type RemoteEntry } from "./catalog.js";
10
+ import { mergeCatalog, computeTemplateUpdates, type RemoteEntry } from "./catalog.js";
11
11
  import type { WorkspaceYaml } from "./types.js";
12
12
 
13
13
  const BUNDLE_VERSION = "0.6.0";
@@ -154,3 +154,101 @@ test("maps a bundled template's meta fields onto the entry", () => {
154
154
  assert.equal(e.category, "workspace");
155
155
  assert.equal(e.isPrivate, true, "isPublic:false ⇒ isPrivate");
156
156
  });
157
+
158
+ // ── computeTemplateUpdates ───────────────────────────────────────────────────
159
+
160
+ test("computeTemplateUpdates: no update when installed equals catalog latest (semver)", () => {
161
+ const { entries } = mergeCatalog({
162
+ bundled: [bundled("crm")],
163
+ remote: [remote("crm", { version: "0.7.0" })],
164
+ bundleVersion: BUNDLE_VERSION, // remote strictly newer → remote wins, carries "0.7.0"
165
+ });
166
+ const [check] = computeTemplateUpdates([{ slug: "crm", version: "0.7.0" }], entries);
167
+ assert.equal(check.latestVersion, "0.7.0");
168
+ assert.equal(check.updateAvailable, false);
169
+ });
170
+
171
+ test("computeTemplateUpdates: update available when installed is older (semver)", () => {
172
+ const { entries } = mergeCatalog({
173
+ bundled: [bundled("crm")],
174
+ remote: [remote("crm", { version: "0.7.0" })],
175
+ bundleVersion: BUNDLE_VERSION,
176
+ });
177
+ const [check] = computeTemplateUpdates([{ slug: "crm", version: "0.5.0" }], entries);
178
+ assert.equal(check.latestVersion, "0.7.0");
179
+ assert.equal(check.updateAvailable, true);
180
+ });
181
+
182
+ // NOTE: uses a non-colliding slug so the remote row is kept as a DISTINCT entry
183
+ // regardless of version — a hash string never parses as semver, so on a slug
184
+ // COLLISION `remoteIsStrictlyNewer` would (correctly, per mergeCatalog's existing
185
+ // squatting-defense semantics) keep the bundle winning and never surface the hash.
186
+ test("computeTemplateUpdates: hash-vs-semver — any mismatch with a hash version signals an update", () => {
187
+ const { entries } = mergeCatalog({
188
+ bundled: [bundled("crm")],
189
+ remote: [remote("hash-template", { version: "h-abc123" })],
190
+ bundleVersion: BUNDLE_VERSION,
191
+ });
192
+ const [staleSemver] = computeTemplateUpdates(
193
+ [{ slug: "hash-template", version: "0.5.0" }],
194
+ entries,
195
+ );
196
+ assert.equal(staleSemver.latestVersion, "h-abc123");
197
+ assert.equal(staleSemver.updateAvailable, true, "semver installed vs hash latest ⇒ differ ⇒ update");
198
+
199
+ const [sameHash] = computeTemplateUpdates(
200
+ [{ slug: "hash-template", version: "h-abc123" }],
201
+ entries,
202
+ );
203
+ assert.equal(sameHash.updateAvailable, false, "identical hash ⇒ no update");
204
+
205
+ const [differentHash] = computeTemplateUpdates(
206
+ [{ slug: "hash-template", version: "h-old999" }],
207
+ entries,
208
+ );
209
+ assert.equal(differentHash.updateAvailable, true, "different hash ⇒ update");
210
+ });
211
+
212
+ test("computeTemplateUpdates: slug missing from catalog ⇒ no update, latestVersion undefined", () => {
213
+ const { entries } = mergeCatalog({
214
+ bundled: [bundled("crm")],
215
+ remote: [],
216
+ bundleVersion: BUNDLE_VERSION,
217
+ });
218
+ const [check] = computeTemplateUpdates([{ slug: "ghost-template", version: "1.0.0" }], entries);
219
+ assert.equal(check.latestVersion, undefined);
220
+ assert.equal(check.updateAvailable, false);
221
+ });
222
+
223
+ test("computeTemplateUpdates: bundled winner compares against bundleVersion when passed", () => {
224
+ const { entries } = mergeCatalog({
225
+ bundled: [bundled("crm")],
226
+ remote: [],
227
+ bundleVersion: BUNDLE_VERSION,
228
+ });
229
+ const stale = computeTemplateUpdates(
230
+ [{ slug: "crm", version: "0.1.0" }],
231
+ entries,
232
+ BUNDLE_VERSION,
233
+ );
234
+ assert.equal(stale[0].latestVersion, BUNDLE_VERSION);
235
+ assert.equal(stale[0].updateAvailable, true);
236
+
237
+ const noBundleVersion = computeTemplateUpdates([{ slug: "crm", version: "0.1.0" }], entries);
238
+ assert.equal(noBundleVersion[0].latestVersion, undefined, "no bundleVersion ⇒ nothing to compare");
239
+ assert.equal(noBundleVersion[0].updateAvailable, false);
240
+ });
241
+
242
+ test("computeTemplateUpdates: passes isPrivate through from the winning entry", () => {
243
+ const { entries } = mergeCatalog({
244
+ bundled: [bundled("crm")],
245
+ remote: [remote("private-pack", { isPrivate: true, version: "1.0.0" })],
246
+ bundleVersion: BUNDLE_VERSION,
247
+ });
248
+ const [check] = computeTemplateUpdates(
249
+ [{ slug: "private-pack", version: "0.9.0" }],
250
+ entries,
251
+ );
252
+ assert.equal(check.isPrivate, true);
253
+ assert.equal(check.updateAvailable, true);
254
+ });
package/src/catalog.ts CHANGED
@@ -371,3 +371,96 @@ export function assembleCatalog(input: AssembleCatalogInput): MergeCatalogResult
371
371
  remoteStatus: input.remoteStatus,
372
372
  });
373
373
  }
374
+
375
+ // ============================================================================
376
+ // Installed-vs-catalog update check
377
+ // ============================================================================
378
+
379
+ /** A template a pod has installed — the door reads `settings.packageVersion` off the workspace row. */
380
+ export interface InstalledTemplate {
381
+ slug: string;
382
+ version: string;
383
+ }
384
+
385
+ /** Per-slug verdict: is a newer catalog version available than what's installed? */
386
+ export interface TemplateUpdateCheck {
387
+ slug: string;
388
+ installedVersion: string;
389
+ /** `undefined` when the slug has no matching catalog entry, or a bundled winner and no `bundleVersion` was passed. */
390
+ latestVersion: string | undefined;
391
+ updateAvailable: boolean;
392
+ /** Passed through from the winning catalog entry, so callers can route private updates through the authed path. */
393
+ isPrivate?: boolean;
394
+ }
395
+
396
+ /**
397
+ * `true` when `version` is a CP content-hash stamp (`"h-<hash>"`) rather than semver.
398
+ * Content-hash versions are NOT semver-comparable — see `computeTemplateUpdates`.
399
+ */
400
+ export function isHashVersion(version: string | undefined): boolean {
401
+ return typeof version === "string" && version.startsWith("h-");
402
+ }
403
+
404
+ /**
405
+ * Compare an installed version against a catalog's latest — reusing
406
+ * `remoteIsStrictlyNewer` for the semver path. Either side being a content-hash
407
+ * (`"h-..."`) makes the pair NOT semver-comparable, so an update is signalled
408
+ * whenever the two raw strings simply differ (equal ⇒ no update, by construction
409
+ * the CP only reissues a new hash when the template content actually changed).
410
+ */
411
+ function isNewerVersion(installedVersion: string, latestVersion: string | undefined): boolean {
412
+ if (latestVersion === undefined) return false;
413
+ if (isHashVersion(installedVersion) || isHashVersion(latestVersion)) {
414
+ return installedVersion !== latestVersion;
415
+ }
416
+ return remoteIsStrictlyNewer(latestVersion, installedVersion);
417
+ }
418
+
419
+ /**
420
+ * Compute, per installed template, whether the CATALOG (bundled + remote,
421
+ * already merged by `mergeCatalog`/`assembleCatalog`) carries a newer version
422
+ * than what the pod installed — the shared "update available" check all three
423
+ * doors (browser, synap-app, CLI) reuse instead of re-deriving it.
424
+ *
425
+ * `installed` and `catalog` are BOTH already-fetched/computed by the caller —
426
+ * this stays PURE, same as the rest of this module (no fetch, no auth).
427
+ *
428
+ * The catalog's winning entry carries the latest version signal two ways:
429
+ * • `source: "remote"` → `remote.definition?.sourcePackage?.version` (semver
430
+ * OR, going forward, a CP content-hash `"h-<hash>"` stamp).
431
+ * • `source: "bundled"` → bundled entries carry no version field at all
432
+ * (`TemplateMeta` has none); pass `bundleVersion` (this client's compiled
433
+ * package version, the same value fed to `mergeCatalog`) to compare against
434
+ * it. Omit `bundleVersion` and bundled-backed slugs report `latestVersion:
435
+ * undefined, updateAvailable: false` (nothing comparable).
436
+ *
437
+ * A slug with no matching catalog entry ⇒ `updateAvailable: false`,
438
+ * `latestVersion: undefined` — there is nothing to update TO.
439
+ */
440
+ export function computeTemplateUpdates(
441
+ installed: InstalledTemplate[],
442
+ catalog: CatalogEntry[],
443
+ bundleVersion?: string,
444
+ ): TemplateUpdateCheck[] {
445
+ const bySlug = new Map(catalog.map((entry) => [entry.slug, entry]));
446
+
447
+ return installed.map(({ slug, version: installedVersion }) => {
448
+ const entry = bySlug.get(slug);
449
+ if (!entry) {
450
+ return { slug, installedVersion, latestVersion: undefined, updateAvailable: false };
451
+ }
452
+
453
+ const latestVersion =
454
+ entry.source === "remote"
455
+ ? entry.remote.definition?.sourcePackage?.version
456
+ : bundleVersion;
457
+
458
+ return {
459
+ slug,
460
+ installedVersion,
461
+ latestVersion,
462
+ updateAvailable: isNewerVersion(installedVersion, latestVersion),
463
+ isPrivate: entry.isPrivate,
464
+ };
465
+ });
466
+ }
package/src/index.ts CHANGED
@@ -61,7 +61,13 @@ export {
61
61
 
62
62
  export type { WorkspaceTemplateSlug } from "./templates.js";
63
63
 
64
- export { mergeCatalog, rowToRemoteEntry, assembleCatalog } from "./catalog.js";
64
+ export {
65
+ mergeCatalog,
66
+ rowToRemoteEntry,
67
+ assembleCatalog,
68
+ computeTemplateUpdates,
69
+ isHashVersion,
70
+ } from "./catalog.js";
65
71
  export type {
66
72
  MergeCatalogInput,
67
73
  MergeCatalogResult,
@@ -73,6 +79,8 @@ export type {
73
79
  RemoteCatalogEntry,
74
80
  CpPackageRow,
75
81
  AssembleCatalogInput,
82
+ InstalledTemplate,
83
+ TemplateUpdateCheck,
76
84
  } from "./catalog.js";
77
85
 
78
86
  export {