@platforma-sdk/package-builder-lib 1.1.0 → 1.2.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.
Files changed (99) hide show
  1. package/dist/core/archive.cjs +3 -3
  2. package/dist/core/archive.cjs.map +1 -1
  3. package/dist/core/archive.js.map +1 -1
  4. package/dist/core/conda/builder.cjs +9 -4
  5. package/dist/core/conda/builder.cjs.map +1 -1
  6. package/dist/core/conda/builder.js +5 -1
  7. package/dist/core/conda/builder.js.map +1 -1
  8. package/dist/core/conda/validate-spec.cjs +2 -2
  9. package/dist/core/conda/validate-spec.cjs.map +1 -1
  10. package/dist/core/conda/validate-spec.js.map +1 -1
  11. package/dist/core/core.cjs +29 -8
  12. package/dist/core/core.cjs.map +1 -1
  13. package/dist/core/core.d.ts +6 -1
  14. package/dist/core/core.d.ts.map +1 -1
  15. package/dist/core/core.js +27 -6
  16. package/dist/core/core.js.map +1 -1
  17. package/dist/core/docker-conda.cjs +2 -2
  18. package/dist/core/docker-conda.cjs.map +1 -1
  19. package/dist/core/docker-conda.js.map +1 -1
  20. package/dist/core/docker-python.cjs +3 -2
  21. package/dist/core/docker-python.cjs.map +1 -1
  22. package/dist/core/docker-python.js +1 -1
  23. package/dist/core/docker-python.js.map +1 -1
  24. package/dist/core/docker.cjs +3 -3
  25. package/dist/core/docker.cjs.map +1 -1
  26. package/dist/core/docker.js.map +1 -1
  27. package/dist/core/envs.cjs +34 -0
  28. package/dist/core/envs.cjs.map +1 -1
  29. package/dist/core/envs.d.ts +8 -2
  30. package/dist/core/envs.d.ts.map +1 -1
  31. package/dist/core/envs.js +13 -1
  32. package/dist/core/envs.js.map +1 -1
  33. package/dist/core/package-info.cjs +59 -5
  34. package/dist/core/package-info.cjs.map +1 -1
  35. package/dist/core/package-info.d.ts +11 -2
  36. package/dist/core/package-info.d.ts.map +1 -1
  37. package/dist/core/package-info.js +60 -6
  38. package/dist/core/package-info.js.map +1 -1
  39. package/dist/core/paths.cjs +1 -1
  40. package/dist/core/paths.cjs.map +1 -1
  41. package/dist/core/paths.js +1 -1
  42. package/dist/core/paths.js.map +1 -1
  43. package/dist/core/resolver.cjs +7 -2
  44. package/dist/core/resolver.cjs.map +1 -1
  45. package/dist/core/resolver.js +1 -1
  46. package/dist/core/resolver.js.map +1 -1
  47. package/dist/core/schemas/artifacts.cjs +7 -1
  48. package/dist/core/schemas/artifacts.cjs.map +1 -1
  49. package/dist/core/schemas/artifacts.d.ts +1 -1
  50. package/dist/core/schemas/artifacts.d.ts.map +1 -1
  51. package/dist/core/schemas/artifacts.js +1 -1
  52. package/dist/core/schemas/artifacts.js.map +1 -1
  53. package/dist/core/schemas/entrypoints.cjs +4 -1
  54. package/dist/core/schemas/entrypoints.cjs.map +1 -1
  55. package/dist/core/schemas/entrypoints.d.ts +1 -1
  56. package/dist/core/schemas/entrypoints.d.ts.map +1 -1
  57. package/dist/core/schemas/entrypoints.js +1 -1
  58. package/dist/core/schemas/entrypoints.js.map +1 -1
  59. package/dist/core/schemas/sw-json.cjs +16 -1
  60. package/dist/core/schemas/sw-json.cjs.map +1 -1
  61. package/dist/core/schemas/sw-json.js +1 -1
  62. package/dist/core/schemas/sw-json.js.map +1 -1
  63. package/dist/core/storage.cjs +6 -2
  64. package/dist/core/storage.cjs.map +1 -1
  65. package/dist/core/storage.js +4 -1
  66. package/dist/core/storage.js.map +1 -1
  67. package/dist/core/sw-json-render.cjs +38 -7
  68. package/dist/core/sw-json-render.cjs.map +1 -1
  69. package/dist/core/sw-json-render.js +37 -8
  70. package/dist/core/sw-json-render.js.map +1 -1
  71. package/dist/core/util.cjs +33 -12
  72. package/dist/core/util.cjs.map +1 -1
  73. package/dist/core/util.d.ts +12 -7
  74. package/dist/core/util.d.ts.map +1 -1
  75. package/dist/core/util.js +17 -8
  76. package/dist/core/util.js.map +1 -1
  77. package/dist/defaults.cjs +18 -1
  78. package/dist/defaults.cjs.map +1 -1
  79. package/dist/defaults.d.ts +9 -3
  80. package/dist/defaults.d.ts.map +1 -1
  81. package/dist/defaults.js +13 -2
  82. package/dist/defaults.js.map +1 -1
  83. package/dist/index.cjs.map +1 -1
  84. package/dist/index.d.ts +4 -2
  85. package/dist/index.d.ts.map +1 -1
  86. package/dist/index.js.map +1 -1
  87. package/package.json +3 -3
  88. package/src/core/core.test.ts +17 -0
  89. package/src/core/core.ts +48 -8
  90. package/src/core/envs.ts +8 -0
  91. package/src/core/package-info.test.ts +162 -3
  92. package/src/core/package-info.ts +88 -3
  93. package/src/core/schemas/test-artifacts.ts +1 -1
  94. package/src/core/sw-json-render.test.ts +23 -0
  95. package/src/core/sw-json-render.ts +30 -2
  96. package/src/core/util.test.ts +52 -0
  97. package/src/core/util.ts +27 -7
  98. package/src/defaults.ts +19 -1
  99. package/src/index.ts +4 -0
@@ -1,13 +1,18 @@
1
1
  import { PackageInfo } from "./package-info";
2
2
  import * as testArtifacts from "./schemas/test-artifacts";
3
- import { createLogger } from "./util";
4
- import { test, expect } from "vitest";
3
+ import { createLogger, isDevMode, producesRegistryDescriptor } from "./util";
4
+ import * as envs from "./envs";
5
+ import * as defaults from "../defaults";
6
+ import { test, expect, afterEach } from "vitest";
7
+ import fs from "node:fs";
8
+ import os from "node:os";
9
+ import path from "node:path";
5
10
 
6
11
  test("PackageInfo loads correctly for single binary package", () => {
7
12
  const l = createLogger("error");
8
13
 
9
14
  const i = new PackageInfo(l, {
10
- pkgJsonData: testArtifacts.SingleBinaryackageJson,
15
+ pkgJsonData: testArtifacts.SingleBinaryPackageJson,
11
16
  });
12
17
 
13
18
  const binary = i.getArtifact(testArtifacts.EPNameBinary, "binary");
@@ -80,3 +85,157 @@ test("PackageInfo considers version override", () => {
80
85
  const java = i.getArtifact(testArtifacts.EPNameJava, "java");
81
86
  expect(i.artifactVersion(java)).toEqual(customVersion);
82
87
  });
88
+
89
+ test("content-addressable dev naming appends a content hash", () => {
90
+ const l = createLogger("error");
91
+
92
+ // Real package roots so the asset's "./src" content root resolves and can be hashed.
93
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pkg-info-dev-"));
94
+ const root2 = fs.mkdtempSync(path.join(os.tmpdir(), "pkg-info-dev-"));
95
+ const devAsset = (packageRoot: string): string => {
96
+ const pi = new PackageInfo(l, { packageRoot, pkgJsonData: testArtifacts.PackageJson });
97
+ pi.buildMode = "dev-local";
98
+ return pi.artifactVersion(pi.getArtifact(testArtifacts.EPNameAsset, "asset"));
99
+ };
100
+
101
+ try {
102
+ fs.mkdirSync(path.join(root, "src"));
103
+ fs.writeFileSync(path.join(root, "src", "a.txt"), "hello");
104
+
105
+ const i = new PackageInfo(l, { packageRoot: root, pkgJsonData: testArtifacts.PackageJson });
106
+ const asset = i.getArtifact(testArtifacts.EPNameAsset, "asset");
107
+ const docker = i.getArtifact(testArtifacts.EPNameDocker, "docker");
108
+
109
+ // Release (default): version-derived, no suffix.
110
+ expect(i.artifactVersion(asset)).toEqual(testArtifacts.PackageVersion);
111
+
112
+ // Dev: a `-<12 hex>` content suffix, deterministic for unchanged content.
113
+ i.buildMode = "dev-local";
114
+ const dev1 = i.artifactVersion(asset);
115
+ expect(dev1).toMatch(new RegExp(`^${testArtifacts.PackageVersion}-[0-9a-f]{12}$`));
116
+ expect(i.artifactVersion(asset)).toEqual(dev1);
117
+
118
+ // Docker stays version-derived (already content-addressed via its image-ID tag).
119
+ expect(i.artifactVersion(docker)).toEqual(testArtifacts.PackageVersion);
120
+
121
+ // Reproducible: identical content at a different absolute location yields the same suffix
122
+ // (hash is over relative paths + file contents, sorted — not the build directory).
123
+ fs.mkdirSync(path.join(root2, "src"));
124
+ fs.writeFileSync(path.join(root2, "src", "a.txt"), "hello");
125
+ expect(devAsset(root2)).toEqual(dev1);
126
+
127
+ // Changed content ⟹ a different name (a fresh build over the changed tree).
128
+ fs.writeFileSync(path.join(root, "src", "b.txt"), "world");
129
+ expect(devAsset(root)).not.toEqual(dev1);
130
+ } finally {
131
+ fs.rmSync(root, { recursive: true, force: true });
132
+ fs.rmSync(root2, { recursive: true, force: true });
133
+ }
134
+ });
135
+
136
+ test("dev content hash: absent root yields no suffix; a pinned version still gets one", () => {
137
+ const l = createLogger("error");
138
+ const empty = fs.mkdtempSync(path.join(os.tmpdir(), "pkg-info-noroot-"));
139
+ const withSrc = fs.mkdtempSync(path.join(os.tmpdir(), "pkg-info-src-"));
140
+ try {
141
+ // No ./src on disk → the asset's content root is absent → no hash suffix, even in dev.
142
+ const noRoot = new PackageInfo(l, {
143
+ packageRoot: empty,
144
+ pkgJsonData: testArtifacts.PackageJson,
145
+ });
146
+ noRoot.buildMode = "dev-local";
147
+ expect(noRoot.artifactVersion(noRoot.getArtifact(testArtifacts.EPNameAsset, "asset"))).toEqual(
148
+ testArtifacts.PackageVersion,
149
+ );
150
+
151
+ // Present root + explicit --version override: dev naming stays content-addressable, so the
152
+ // pinned version still gets the -<hash> suffix (a same-name re-push would be silently stale).
153
+ fs.mkdirSync(path.join(withSrc, "src"));
154
+ fs.writeFileSync(path.join(withSrc, "src", "a.txt"), "hello");
155
+ const pinned = new PackageInfo(l, {
156
+ packageRoot: withSrc,
157
+ pkgJsonData: testArtifacts.PackageJson,
158
+ });
159
+ pinned.buildMode = "dev-local";
160
+ pinned.version = "9.9.9";
161
+ expect(pinned.artifactVersion(pinned.getArtifact(testArtifacts.EPNameAsset, "asset"))).toMatch(
162
+ /^9\.9\.9-[0-9a-f]{12}$/,
163
+ );
164
+ } finally {
165
+ fs.rmSync(empty, { recursive: true, force: true });
166
+ fs.rmSync(withSrc, { recursive: true, force: true });
167
+ }
168
+ });
169
+
170
+ test("dev content hash is memoized for the builder's lifetime", () => {
171
+ const l = createLogger("error");
172
+ const root = fs.mkdtempSync(path.join(os.tmpdir(), "pkg-info-memo-"));
173
+ try {
174
+ fs.mkdirSync(path.join(root, "src"));
175
+ fs.writeFileSync(path.join(root, "src", "a.txt"), "hello");
176
+ const i = new PackageInfo(l, { packageRoot: root, pkgJsonData: testArtifacts.PackageJson });
177
+ i.buildMode = "dev-local";
178
+ const asset = i.getArtifact(testArtifacts.EPNameAsset, "asset");
179
+ const first = i.artifactVersion(asset);
180
+ // Mutate the tree; content roots are read once per builder, so the cached hash must not change.
181
+ fs.writeFileSync(path.join(root, "src", "b.txt"), "world");
182
+ expect(i.artifactVersion(asset)).toEqual(first);
183
+ } finally {
184
+ fs.rmSync(root, { recursive: true, force: true });
185
+ }
186
+ });
187
+
188
+ test("build-mode predicates split channel from descriptor shape", () => {
189
+ expect(isDevMode("dev-local")).toBe(true);
190
+ expect(isDevMode("dev-remote")).toBe(true);
191
+ expect(isDevMode("release")).toBe(false);
192
+
193
+ // dev-local is the only same-host mode: it does NOT build an archive / registry descriptor.
194
+ expect(producesRegistryDescriptor("dev-local")).toBe(false);
195
+ expect(producesRegistryDescriptor("dev-remote")).toBe(true);
196
+ expect(producesRegistryDescriptor("release")).toBe(true);
197
+ });
198
+
199
+ const devUploadEnv = envs.PL_DEV_BINARY_UPLOAD_URL;
200
+ const releaseUploadEnv = envs.PL_RELEASE_BINARY_UPLOAD_URL;
201
+ afterEach(() => {
202
+ delete process.env[devUploadEnv];
203
+ delete process.env[releaseUploadEnv];
204
+ });
205
+
206
+ test("dev channel flips the embedded binary registry name to midev/dev", () => {
207
+ const l = createLogger("error");
208
+ const i = new PackageInfo(l, { pkgJsonData: testArtifacts.SingleBinaryPackageJson });
209
+ const binary = i.getArtifact(testArtifacts.EPNameBinary, "binary");
210
+
211
+ // Release: keeps the artifact's resolved registry name (default platforma-open here).
212
+ const releaseName = i.artifactRegistrySettings(binary).name;
213
+ expect(releaseName).toEqual("platforma-open");
214
+
215
+ // Dev, built-in default: name is midev, upload routes to the built-in midev endpoint.
216
+ i.buildMode = "dev-remote";
217
+ const devDefault = i.artifactRegistrySettings(binary);
218
+ expect(devDefault.name).toEqual("midev");
219
+ expect(devDefault.storageURL).toEqual(defaults.DEV_BINARY_UPLOAD_TARGET);
220
+
221
+ // Dev with an explicit endpoint: name flips to dev and the upload URL routes there.
222
+ process.env[devUploadEnv] = "s3://my-bucket/dev?region=eu-central-1";
223
+ const dev = i.artifactRegistrySettings(binary);
224
+ expect(dev.name).toEqual("dev");
225
+ expect(dev.storageURL).toEqual("s3://my-bucket/dev?region=eu-central-1");
226
+
227
+ // dev-local channel behaves the same for naming.
228
+ i.buildMode = "dev-local";
229
+ expect(i.artifactRegistrySettings(binary).name).toEqual("dev");
230
+ });
231
+
232
+ test("release honors PL_RELEASE_BINARY_UPLOAD_URL without renaming", () => {
233
+ const l = createLogger("error");
234
+ const i = new PackageInfo(l, { pkgJsonData: testArtifacts.SingleBinaryPackageJson });
235
+ const binary = i.getArtifact(testArtifacts.EPNameBinary, "binary");
236
+
237
+ process.env[releaseUploadEnv] = "s3://release-bucket/pub?region=eu-central-1";
238
+ const rel = i.artifactRegistrySettings(binary);
239
+ expect(rel.name).toEqual("platforma-open");
240
+ expect(rel.storageURL).toEqual("s3://release-bucket/pub?region=eu-central-1");
241
+ });
@@ -1,5 +1,6 @@
1
1
  import * as path from "node:path";
2
2
  import * as fs from "node:fs";
3
+ import { createHash } from "node:crypto";
3
4
  import type winston from "winston";
4
5
  import { z } from "zod/v4";
5
6
 
@@ -85,6 +86,9 @@ export class PackageInfo {
85
86
  private readonly pkgJson: packageJson;
86
87
  private _versionOverride: string | undefined;
87
88
 
89
+ /** Backing store for `Core.buildMode` (Core delegates its accessor here); gates content-addressable dev naming. */
90
+ public buildMode: util.BuildMode = "release";
91
+
88
92
  constructor(
89
93
  private logger: winston.Logger,
90
94
  options?: {
@@ -499,7 +503,20 @@ export class PackageInfo {
499
503
  return util.trimPrefix(this.pkgJson.name, "@") + "/" + artifact.id;
500
504
  }
501
505
 
502
- public artifactVersion(artifact: artifacts.anyArtifactType): string {
506
+ public artifactVersion(artifact: artifacts.withId<artifacts.anyArtifactType>): string {
507
+ const base = this.baseArtifactVersion(artifact);
508
+
509
+ if (util.isDevMode(this.buildMode) && artifact.type !== "docker") {
510
+ const hash = this.devContentHash(artifact);
511
+ if (hash) {
512
+ return `${base}-${hash}`;
513
+ }
514
+ }
515
+
516
+ return base;
517
+ }
518
+
519
+ private baseArtifactVersion(artifact: artifacts.withId<artifacts.anyArtifactType>): string {
503
520
  if (this._versionOverride) {
504
521
  return this._versionOverride;
505
522
  }
@@ -515,6 +532,58 @@ export class PackageInfo {
515
532
  return this.pkgJson.version;
516
533
  }
517
534
 
535
+ /**
536
+ * Short content hash of the artifact's content root(s), used as the dev version suffix.
537
+ */
538
+ private readonly devContentHashCache = new Map<string, string | undefined>();
539
+
540
+ private devContentHash(
541
+ artifact: artifacts.withId<artifacts.anyArtifactType>,
542
+ ): string | undefined {
543
+ // Content roots are stable for the lifetime of this builder, so hash each artifact once.
544
+ if (this.devContentHashCache.has(artifact.id)) {
545
+ return this.devContentHashCache.get(artifact.id);
546
+ }
547
+
548
+ const hash = this.computeDevContentHash(artifact);
549
+ this.devContentHashCache.set(artifact.id, hash);
550
+ return hash;
551
+ }
552
+
553
+ private computeDevContentHash(
554
+ artifact: artifacts.withId<artifacts.anyArtifactType>,
555
+ ): string | undefined {
556
+ const hasher = createHash("sha256");
557
+ let hashedAny = false;
558
+
559
+ let platforms: util.PlatformType[];
560
+ try {
561
+ // Sorted so the hash is independent of package.json root-key order.
562
+ platforms = this.artifactPlatforms(artifact).toSorted();
563
+ } catch {
564
+ return undefined; // no content roots at all — leave the version without a suffix
565
+ }
566
+
567
+ for (const platform of platforms) {
568
+ let root: string;
569
+ try {
570
+ root = this.artifactContentRoot(artifact, platform);
571
+ } catch {
572
+ continue; // platform without a defined/present root — skip
573
+ }
574
+ if (!fs.existsSync(root)) {
575
+ continue;
576
+ }
577
+ // Hash the platform name too, so the same files under a different platform yield a different
578
+ // hash.
579
+ hasher.update(platform);
580
+ util.hashDirSync(root, hasher);
581
+ hashedAny = true;
582
+ }
583
+
584
+ return hashedAny ? hasher.digest("hex").slice(0, 12) : undefined;
585
+ }
586
+
518
587
  public artifactRegistrySettings(artifact: artifacts.anyArtifactType): artifacts.registry {
519
588
  if (artifact.type === "docker") {
520
589
  return {
@@ -559,6 +628,21 @@ export class PackageInfo {
559
628
  result.downloadURL = downloadFrom;
560
629
  }
561
630
 
631
+ // Dev channel: the embedded binary registry is the built-in `midev` uploading to its built-in
632
+ // endpoint, flipping to `dev` and `PL_DEV_BINARY_UPLOAD_URL` when that is set.
633
+ if (util.isDevMode(this.buildMode)) {
634
+ const devUpload = process.env[envs.PL_DEV_BINARY_UPLOAD_URL];
635
+ result.name = devUpload
636
+ ? defaults.DEV_BIN_REGISTRY_NAME_OVERRIDDEN
637
+ : defaults.DEV_BIN_REGISTRY_NAME;
638
+ result.storageURL = devUpload ?? defaults.DEV_BINARY_UPLOAD_TARGET;
639
+ } else {
640
+ const releaseUpload = process.env[envs.PL_RELEASE_BINARY_UPLOAD_URL];
641
+ if (releaseUpload) {
642
+ result.storageURL = releaseUpload;
643
+ }
644
+ }
645
+
562
646
  if (result.downloadURL) {
563
647
  const u = new URL(result.downloadURL, "file:/nonexistent"); // check download URL is valid URL
564
648
  if (!["https:", "http:"].includes(u.protocol)) {
@@ -718,8 +802,9 @@ export class PackageInfo {
718
802
  hasErrors = true;
719
803
  }
720
804
 
721
- const name = this.artifactName({ id: artifactID, ...artifact });
722
- const version = this.artifactVersion(artifact);
805
+ const withId = { id: artifactID, ...artifact };
806
+ const name = this.artifactName(withId);
807
+ const version = this.artifactVersion(withId);
723
808
  const uniqueName = `${name}-${version}`;
724
809
  if (uniquePackageNames.has(uniqueName)) {
725
810
  this.logger.error(
@@ -346,7 +346,7 @@ export const PackageJson = `{
346
346
  }
347
347
  }`;
348
348
 
349
- export const SingleBinaryackageJson = `{
349
+ export const SingleBinaryPackageJson = `{
350
350
  "name": "${PackageName}",
351
351
  "version": "${PackageVersion}",
352
352
  "block-software": {
@@ -156,6 +156,29 @@ describe("Renderer tests", () => {
156
156
  expect(javaDescriptor.docker!.cmd).toEqual(["java", "-jar", "hello.jar"]);
157
157
  });
158
158
 
159
+ test("render no-software placeholder", () => {
160
+ const sw = new SwJsonRenderer(l, i);
161
+
162
+ // No artifact info written — placeholders need nothing built. A docker+binary pair collapses
163
+ // to a single placeholder for their shared origin entrypoint.
164
+ const epName = test_assets.EPNameJava;
165
+ const eps = new Map([
166
+ [docker.entrypointName(epName), i.getMainEntrypoint(docker.entrypointName(epName))],
167
+ [epName, i.getMainEntrypoint(epName)],
168
+ ]);
169
+ const result = sw.renderPlaceholderEntrypoints(eps);
170
+
171
+ expect(result.size).toBe(1);
172
+ const descriptor = result.get(epName)!;
173
+ expect(descriptor.isDev).toBe(true);
174
+ expect(descriptor.binary).toEqual({
175
+ type: "binary",
176
+ registry: defaults.NO_SOFTWARE_PLACEHOLDER,
177
+ package: defaults.NO_SOFTWARE_PLACEHOLDER,
178
+ cmd: ["true"],
179
+ });
180
+ });
181
+
159
182
  test("render docker indinvidual entrypoint", () => {
160
183
  const dockerEpName = test_assets.EPNameDocker;
161
184
  const dockerPkg = i.getArtifact(dockerEpName, "docker");
@@ -58,12 +58,12 @@ export class SwJsonRenderer {
58
58
  throw util.CLIError(`Entrypoint ${epName} not found in result`);
59
59
  }
60
60
 
61
- if (mode !== "release") {
61
+ if (util.isDevMode(mode)) {
62
62
  info.isDev = true;
63
63
  }
64
64
 
65
65
  const pkg = ep.artifact;
66
- if (util.isDevLocalMode(mode)) {
66
+ if (!util.producesRegistryDescriptor(mode)) {
67
67
  switch (pkg.type) {
68
68
  case "docker": {
69
69
  info.docker = this.renderDockerInfo(epName, ep, options?.requireAllArtifacts);
@@ -122,6 +122,31 @@ export class SwJsonRenderer {
122
122
  return result;
123
123
  }
124
124
 
125
+ // One minimal `binary` placeholder per entrypoint, so a block builds, loads, and renders without
126
+ // any software; only executing it fails, against the sentinel registry. References are copied
127
+ // through by the caller, so they are skipped here.
128
+ public renderPlaceholderEntrypoints(
129
+ entrypoints: Map<string, entrypoint.Entrypoint>,
130
+ ): Map<string, swJson.swJsonType> {
131
+ const result = new Map<string, swJson.swJsonType>();
132
+ for (const [epName, ep] of entrypoints.entries()) {
133
+ if (ep.type === "reference") continue;
134
+ const originEpName = docker.entrypointNameToOrigin(epName);
135
+ if (result.has(originEpName)) continue;
136
+ result.set(originEpName, {
137
+ id: { package: this.pkgInfo.packageName, name: originEpName },
138
+ isDev: true,
139
+ binary: {
140
+ type: "binary",
141
+ registry: defaults.NO_SOFTWARE_PLACEHOLDER,
142
+ package: defaults.NO_SOFTWARE_PLACEHOLDER,
143
+ cmd: ["true"],
144
+ },
145
+ });
146
+ }
147
+ return result;
148
+ }
149
+
125
150
  public writeSwJson(info: swJson.swJsonType, dstFile?: string) {
126
151
  const epType = info.asset ? "asset" : "software";
127
152
  const dstSwInfoPath =
@@ -297,6 +322,7 @@ export class SwJsonRenderer {
297
322
  ): swJson.remoteSoftwareType | undefined {
298
323
  switch (mode) {
299
324
  case "release":
325
+ case "dev-remote":
300
326
  break;
301
327
 
302
328
  case "dev-local":
@@ -433,6 +459,7 @@ export class SwJsonRenderer {
433
459
  ): swJson.runEnvInfo | undefined {
434
460
  switch (mode) {
435
461
  case "release":
462
+ case "dev-remote":
436
463
  break;
437
464
 
438
465
  case "dev-local":
@@ -482,6 +509,7 @@ export class SwJsonRenderer {
482
509
  ): swJson.assetInfo | undefined {
483
510
  switch (mode) {
484
511
  case "release":
512
+ case "dev-remote":
485
513
  break;
486
514
 
487
515
  case "dev-local":
@@ -0,0 +1,52 @@
1
+ import { hashDirSync, shouldDoAction } from "./util";
2
+ import { test, expect } from "vitest";
3
+ import fs from "node:fs";
4
+ import os from "node:os";
5
+ import path from "node:path";
6
+
7
+ const digest = (root: string): string => hashDirSync(root).digest("hex");
8
+
9
+ // Nested entries must hash under their full path from the root, otherwise distinct trees collide
10
+ // and content-addressable dev naming would serve a stale artifact.
11
+ test("hashDirSync distinguishes a nested file from a same-named root file", () => {
12
+ const a = fs.mkdtempSync(path.join(os.tmpdir(), "hash-a-"));
13
+ const b = fs.mkdtempSync(path.join(os.tmpdir(), "hash-b-"));
14
+ try {
15
+ // Tree A: an empty `dir/` plus a root `file`.
16
+ fs.mkdirSync(path.join(a, "dir"));
17
+ fs.writeFileSync(path.join(a, "file"), "x");
18
+
19
+ // Tree B: the same `file` (same contents) but nested inside `dir/`.
20
+ fs.mkdirSync(path.join(b, "dir"));
21
+ fs.writeFileSync(path.join(b, "dir", "file"), "x");
22
+
23
+ expect(digest(a)).not.toEqual(digest(b));
24
+ } finally {
25
+ fs.rmSync(a, { recursive: true, force: true });
26
+ fs.rmSync(b, { recursive: true, force: true });
27
+ }
28
+ });
29
+
30
+ test("hashDirSync is identical for identical trees at different locations", () => {
31
+ const a = fs.mkdtempSync(path.join(os.tmpdir(), "hash-a-"));
32
+ const b = fs.mkdtempSync(path.join(os.tmpdir(), "hash-b-"));
33
+ try {
34
+ for (const root of [a, b]) {
35
+ fs.mkdirSync(path.join(root, "dir"));
36
+ fs.writeFileSync(path.join(root, "dir", "nested"), "hello");
37
+ fs.writeFileSync(path.join(root, "top"), "world");
38
+ }
39
+ expect(digest(a)).toEqual(digest(b));
40
+ } finally {
41
+ fs.rmSync(a, { recursive: true, force: true });
42
+ fs.rmSync(b, { recursive: true, force: true });
43
+ }
44
+ });
45
+
46
+ test("shouldDoAction: disable wins over enable wins over the default", () => {
47
+ expect(shouldDoAction({ default: true })).toBe(true);
48
+ expect(shouldDoAction({ default: false })).toBe(false);
49
+ expect(shouldDoAction({ default: false, enable: true })).toBe(true);
50
+ expect(shouldDoAction({ default: true, disable: true })).toBe(false);
51
+ expect(shouldDoAction({ default: false, enable: true, disable: true })).toBe(false);
52
+ });
package/src/core/util.ts CHANGED
@@ -10,7 +10,7 @@ import * as envs from "./envs";
10
10
  export const packageJsonName = "package.json";
11
11
  export const softwareConfigName = "package.json";
12
12
 
13
- export function assertNever(_a: never) {
13
+ export function assertNever(_a: never): never {
14
14
  throw new Error(`code logic error: assertNever() call for ${JSON.stringify(_a)}`);
15
15
  }
16
16
 
@@ -51,7 +51,10 @@ export function hashDirSync(rootDir: string, hasher?: Hash, subdir?: string): Ha
51
51
  const hash = hasher ? hasher : createHash("sha256");
52
52
  const folder = path.join(rootDir, subdir ?? ".");
53
53
 
54
- const info = fs.readdirSync(folder, { withFileTypes: true });
54
+ // Sort for a host-independent hash: readdir enumeration order is not guaranteed.
55
+ const info = fs
56
+ .readdirSync(folder, { withFileTypes: true })
57
+ .sort((a, b) => (a.name < b.name ? -1 : a.name > b.name ? 1 : 0));
55
58
 
56
59
  for (const item of info) {
57
60
  const relPath = path.join(subdir ?? ".", item.name);
@@ -70,7 +73,9 @@ export function hashDirSync(rootDir: string, hasher?: Hash, subdir?: string): Ha
70
73
 
71
74
  fs.closeSync(fileDescriptor);
72
75
  } else if (item.isDirectory()) {
73
- hashDirSync(fullPath, hash);
76
+ // Keep rootDir fixed and pass the accumulated relPath so nested entries hash under their
77
+ // full path from the root — otherwise `dirA/` + `file` collides with `dirA/file`.
78
+ hashDirSync(rootDir, hash, relPath);
74
79
  }
75
80
  }
76
81
 
@@ -295,11 +300,26 @@ export function currentPlatform(): PlatformType {
295
300
  export const AllSoftwareSources = ["archive", "docker"] as const; // add 'image', '<whatever>' here when supported
296
301
  export type SoftwareSource = (typeof AllSoftwareSources)[number];
297
302
 
298
- export type BuildMode = "dev-local" | "release";
303
+ export type BuildMode = "dev-local" | "dev-remote" | "release";
304
+
305
+ export function isDevMode(mode: BuildMode): mode is "dev-local" | "dev-remote" {
306
+ return mode === "dev-local" || mode === "dev-remote";
307
+ }
308
+
309
+ export function producesRegistryDescriptor(mode: BuildMode): mode is "dev-remote" | "release" {
310
+ return mode === "dev-remote" || mode === "release";
311
+ }
299
312
 
300
- /** True for dev-local build mode. */
301
- export function isDevLocalMode(mode: BuildMode): mode is "dev-local" {
302
- return mode === "dev-local";
313
+ // Resolve a `--do`/`--no-do` (or PL_*_/PL_*_NO_ env) tri-state: explicit disable wins, then enable,
314
+ // else the default.
315
+ export function shouldDoAction(opts: {
316
+ default: boolean;
317
+ enable?: boolean;
318
+ disable?: boolean;
319
+ }): boolean {
320
+ if (opts.disable) return false;
321
+ if (opts.enable) return true;
322
+ return opts.default;
303
323
  }
304
324
 
305
325
  export type artifactID = {
package/src/defaults.ts CHANGED
@@ -8,6 +8,14 @@
8
8
  export const BIN_REGISTRY_NAME = "platforma-open";
9
9
  export const BIN_REGISTRY_DOWNLOAD_URL = "https://bin.pl-open.science/";
10
10
 
11
+ // Built-in dev binary registry name; flips to DEV_BIN_REGISTRY_NAME_OVERRIDDEN under PL_DEV_BINARY_UPLOAD_URL.
12
+ export const DEV_BIN_REGISTRY_NAME = "midev";
13
+ export const DEV_BIN_REGISTRY_NAME_OVERRIDDEN = "dev";
14
+
15
+ // Built-in midev dev binary upload endpoint (zero-config dev push), the binary counterpart of
16
+ // DEV_DOCKER_PUSH_TARGET. Override with PL_DEV_BINARY_UPLOAD_URL.
17
+ export const DEV_BINARY_UPLOAD_TARGET = "s3://milab-midev-registry?region=eu-central-1";
18
+
11
19
  // Python
12
20
  export const PYTHON_TOOLSET = "pip";
13
21
  export const PYTHON_REQUIREMENTS_FILE = "requirements.txt";
@@ -27,9 +35,19 @@ export const DOCKER_REGISTRY = "containers.pl-open.science/milaboratories/pl-con
27
35
  export const DOCKER_DOCKERFILE = "Dockerfile"; // inside git repo: path to Dockerfile to build by default
28
36
  export const DOCKER_PLACEHOLDER_PKG = "/app"; // {pkg} placeholder value to by used in 'cmd' (for autogenerated docker images)
29
37
 
38
+ // Sentinel registry/package name for placeholder descriptors, so an accidental execution fails
39
+ // with a recognizable error instead of an opaque registry 404.
40
+ export const NO_SOFTWARE_PLACEHOLDER = "no-software-placeholder";
41
+
30
42
  // Block-software dev pushes (pl-pkg build --dev=local) target this ECR by default.
31
43
  // Override per-run with --docker-registry / PL_DOCKER_REGISTRY.
32
- export const DEV_DOCKER_REGISTRY = "public.ecr.aws/u5p1x5q2/pl-containers";
44
+ export const DEV_DOCKER_REGISTRY = "public.ecr.aws/miresearch/pl-containers";
45
+
46
+ // ecr:// scheme requests automatic docker login; the pull address stays the bare registry.
47
+ export const DEV_DOCKER_PUSH_TARGET = `ecr://${DEV_DOCKER_REGISTRY}`;
48
+
49
+ // Fallback AWS SSO profile for dev push; override with PL_AWS_PROFILE or AWS_PROFILE.
50
+ export const AWS_DEV_PROFILE = "research-poweruser";
33
51
 
34
52
  // All docker images are built for and pushed as linux/amd64, regardless of host arch.
35
53
  // Platforma's K8s nodes are linux/amd64; building for any other platform produces
package/src/index.ts CHANGED
@@ -5,6 +5,9 @@ export * as util from "./core/util";
5
5
  export * as envs from "./core/envs";
6
6
  export * as defaults from "./defaults";
7
7
 
8
+ /** The logger the engine and its callers pass around. */
9
+ export type Logger = winston.Logger;
10
+
8
11
  /**
9
12
  * Public surface of the build engine. Derived from `Core` so signatures never
10
13
  * drift, while the `Core` class itself stays internal (per A-0023: the engine is
@@ -22,6 +25,7 @@ export type Builder = Pick<
22
25
  | "buildDockerImages"
23
26
  | "buildSoftwareArchives"
24
27
  | "buildSwJsonFiles"
28
+ | "writePublishedArtifactInfo"
25
29
  | "publishPackages"
26
30
  | "publishDockerImages"
27
31
  >;