@platforma-sdk/block-tools 2.11.10 → 2.12.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 (37) hide show
  1. package/dist/cli.js +13 -13
  2. package/dist/cli.js.map +1 -1
  3. package/dist/cli.mjs +845 -625
  4. package/dist/cli.mjs.map +1 -1
  5. package/dist/cmd/software/build.d.ts +2 -0
  6. package/dist/cmd/software/ecr-login.d.ts +3 -0
  7. package/dist/cmd/software/env.d.ts +5 -0
  8. package/dist/cmd/software/index.d.ts +2 -0
  9. package/dist/cmd/software/knobs.d.ts +26 -0
  10. package/dist/cmd/software/run-build.d.ts +19 -0
  11. package/dist/structure/engine/version.d.ts +5 -4
  12. package/dist/structure/rules/root-package-json.d.ts +1 -2
  13. package/dist/structure/rules/root-pnpm-workspace.d.ts +7 -1
  14. package/package.json +7 -5
  15. package/src/cli.test.ts +20 -1
  16. package/src/cli.ts +2 -0
  17. package/src/cmd/software/build.ts +175 -0
  18. package/src/cmd/software/ecr-login.test.ts +92 -0
  19. package/src/cmd/software/ecr-login.ts +75 -0
  20. package/src/cmd/software/env.test.ts +17 -0
  21. package/src/cmd/software/env.ts +22 -0
  22. package/src/cmd/software/index.ts +9 -0
  23. package/src/cmd/software/knobs.test.ts +52 -0
  24. package/src/cmd/software/knobs.ts +61 -0
  25. package/src/cmd/software/run-build.test.ts +291 -0
  26. package/src/cmd/software/run-build.ts +156 -0
  27. package/src/structure/__tests__/floor-version.test.ts +2 -2
  28. package/src/structure/engine/__tests__/version.test.ts +14 -17
  29. package/src/structure/engine/discovery-fs.ts +4 -13
  30. package/src/structure/engine/runner.ts +4 -4
  31. package/src/structure/engine/version.ts +11 -13
  32. package/src/structure/rules/migrations.ts +1 -1
  33. package/src/structure/rules/root-package-json.ts +38 -14
  34. package/src/structure/rules/root-pnpm-workspace.ts +8 -1
  35. package/src/structure/rules/root.ts +1 -1
  36. package/src/structure/rules/software-package-json.ts +23 -10
  37. package/src/structure/templates/static/root/turbo.json +15 -1
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function softwareBuildCommand(): Command;
@@ -0,0 +1,3 @@
1
+ import { Logger } from '@platforma-sdk/package-builder-lib';
2
+ export declare function ensureAwsProfile(logger?: Logger): void;
3
+ export declare function ensureEcrLogin(registry: string, logger?: Logger): Promise<void>;
@@ -0,0 +1,5 @@
1
+ export declare const PL_BUILD_CHANNEL = "PL_BUILD_CHANNEL";
2
+ export declare const PL_BUILD_VARIANT = "PL_BUILD_VARIANT";
3
+ export declare const PL_BUILD_LOCATION = "PL_BUILD_LOCATION";
4
+ export declare const PL_BUILD_USE_PUBLISHED = "PL_BUILD_USE_PUBLISHED";
5
+ export declare const softwareBuildCacheEnv: readonly string[];
@@ -0,0 +1,2 @@
1
+ import { Command } from 'commander';
2
+ export declare function softwareCommand(): Command;
@@ -0,0 +1,26 @@
1
+ export declare const channels: readonly ["dev", "release"];
2
+ export declare const variants: readonly ["docker", "binary", "all", "none"];
3
+ export declare const locations: readonly ["local", "remote"];
4
+ export type Channel = (typeof channels)[number];
5
+ export type Variant = (typeof variants)[number];
6
+ export type Location = (typeof locations)[number];
7
+ export type Knobs = {
8
+ channel?: Channel;
9
+ variant?: Variant;
10
+ location?: Location;
11
+ usePublished?: boolean;
12
+ };
13
+ export type TargetVariant = Exclude<Variant, "none">;
14
+ export type Scenario = {
15
+ kind: "binary-existing";
16
+ } | {
17
+ kind: "no-software";
18
+ } | {
19
+ kind: "bare";
20
+ } | {
21
+ kind: "target";
22
+ channel: Channel;
23
+ variant: TargetVariant;
24
+ location: Location;
25
+ };
26
+ export declare function parseScenario(knobs: Knobs): Scenario;
@@ -0,0 +1,19 @@
1
+ import { Builder, Logger } from '@platforma-sdk/package-builder-lib';
2
+ import { Scenario } from './knobs';
3
+ export type BuildOptions = {
4
+ ids?: string[];
5
+ force?: boolean;
6
+ contentRoot?: string;
7
+ storageUrl?: string;
8
+ dockerRegistry?: string;
9
+ dockerPushTo?: string;
10
+ buildDocker?: boolean;
11
+ pushDocker?: boolean;
12
+ condaBuild?: boolean;
13
+ };
14
+ export declare function runBuild(params: {
15
+ core: Builder;
16
+ scenario: Scenario;
17
+ options?: BuildOptions;
18
+ logger?: Logger;
19
+ }): Promise<void>;
@@ -1,13 +1,13 @@
1
1
  import { FileSystem } from './fs/api';
2
- export declare const STRUCTURE_VERSION = 1;
2
+ export declare const STRUCTURE_VERSION = 2;
3
3
  export declare const STRUCTURE_MIN_SUPPORTED = 0;
4
4
  /** Block-relative path of the structurer metadata file. */
5
5
  export declare const STRUCTURE_META_FILE = ".structure";
6
6
  export type StructureMeta = {
7
7
  version: number;
8
8
  };
9
- /** Read `.structure`. Missing file or invalid payload → 0. */
10
- export declare function readStructureVersion(fs: FileSystem): number;
9
+ /** Read `.structure`. Missing file or invalid payload → version 0. */
10
+ export declare function readStructureMeta(fs: FileSystem): StructureMeta;
11
11
  /** Throw if version is below floor. */
12
12
  export declare function assertVersionAboveFloor(version: number, floor?: number): void;
13
13
  export declare class StructureVersionFloorError extends Error {
@@ -15,4 +15,5 @@ export declare class StructureVersionFloorError extends Error {
15
15
  readonly floor: number;
16
16
  constructor(version: number, floor: number);
17
17
  }
18
- export declare function writeStructureVersion(fs: FileSystem, version?: number): void;
18
+ /** Write `.structure`. Writes a fresh payload rather than merging. */
19
+ export declare function writeStructureMeta(fs: FileSystem, meta: StructureMeta): void;
@@ -1,3 +1,2 @@
1
- import { RunContext } from '../engine/api';
2
- export declare function rootPackageJsonInitial(_ctx: RunContext): Record<string, unknown>;
1
+ export declare function rootPackageJsonInitial(): Record<string, unknown>;
3
2
  export declare function rootPackageJsonRules(): void;
@@ -17,7 +17,13 @@ export declare const INFRA_CATALOG_FLOOR: Record<string, string>;
17
17
  * 3.5.x than ui-vue's, yielding two vue instances and a SdkPluginV3-vs-Plugin
18
18
  * type clash in `main.ts`. Deriving the pin from ui-vue's declared dep keeps
19
19
  * the block in lockstep with whatever vue ui-vue's npm-latest pins, with no
20
- * hand-maintained version here. */
20
+ * hand-maintained version here.
21
+ *
22
+ * `@milaboratories/helpers`: the block's model/ui depend on it (catalog:) and
23
+ * `@platforma-sdk/model` pins it as an EXACT regular dependency. A hand-pinned
24
+ * floor that lags a model bump yields two helpers instances — the model's
25
+ * inferred `platforma` type then references the SDK's copy, not the block's,
26
+ * failing `tsc` with TS2742. Deriving from model keeps them in lockstep. */
21
27
  export declare const DERIVED_CATALOG_PINS: readonly DerivedCatalogPin[];
22
28
  /** Catalog name of the python runenv. Added to a block's catalog only when
23
29
  * the block carries a software module. Pinned to an EXACT version — updating
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@platforma-sdk/block-tools",
3
- "version": "2.11.10",
3
+ "version": "2.12.0",
4
4
  "description": "Utility to manipulate Platforma Blocks and Block Registry",
5
5
  "license": "UNLICENSED",
6
6
  "bin": {
@@ -23,6 +23,7 @@
23
23
  }
24
24
  },
25
25
  "dependencies": {
26
+ "@aws-sdk/client-ecr-public": "3.859.0",
26
27
  "@aws-sdk/client-s3": "3.859.0",
27
28
  "@inquirer/prompts": "^7.0.1",
28
29
  "canonicalize": "~2.1.0",
@@ -35,11 +36,12 @@
35
36
  "zod": "~3.25.76",
36
37
  "@milaboratories/pl-http": "1.2.4",
37
38
  "@milaboratories/pl-model-backend": "1.4.13",
38
- "@milaboratories/resolve-helper": "1.1.3",
39
39
  "@milaboratories/pl-model-common": "1.46.4",
40
40
  "@milaboratories/pl-model-middle-layer": "1.30.11",
41
41
  "@platforma-sdk/blocks-deps-updater": "2.2.0",
42
- "@milaboratories/ts-helpers": "1.8.4"
42
+ "@platforma-sdk/package-builder-lib": "1.2.0",
43
+ "@milaboratories/ts-helpers": "1.8.4",
44
+ "@milaboratories/resolve-helper": "1.1.3"
43
45
  },
44
46
  "devDependencies": {
45
47
  "@rollup/plugin-node-resolve": "^16.0.1",
@@ -54,9 +56,9 @@
54
56
  "vite": "^8.0.6",
55
57
  "vite-plugin-dts": "^4.5.3",
56
58
  "vitest": "^4.1.3",
57
- "@milaboratories/ts-builder": "1.6.0",
58
59
  "@milaboratories/build-configs": "2.0.0",
59
- "@milaboratories/ts-configs": "1.3.0"
60
+ "@milaboratories/ts-configs": "1.3.0",
61
+ "@milaboratories/ts-builder": "1.6.0"
60
62
  },
61
63
  "scripts": {
62
64
  "build": "vite build",
package/src/cli.test.ts CHANGED
@@ -15,7 +15,7 @@ function longNames(cmd: Command): string[] {
15
15
  describe("block-tools CLI structure", () => {
16
16
  const program = buildProgram();
17
17
 
18
- it("exposes the full command surface (10 commands + structure topic)", () => {
18
+ it("exposes the full command surface (11 commands + structure topic)", () => {
19
19
  expect(program.commands.map((c) => c.name()).sort()).toEqual([
20
20
  "build-meta",
21
21
  "build-model",
@@ -25,6 +25,7 @@ describe("block-tools CLI structure", () => {
25
25
  "publish",
26
26
  "refresh-registry",
27
27
  "restore-overview-from-snapshot",
28
+ "software",
28
29
  "structure",
29
30
  "update-deps",
30
31
  "upload-package-v1",
@@ -34,6 +35,24 @@ describe("block-tools CLI structure", () => {
34
35
  .commands.map((c) => c.name())
35
36
  .sort(),
36
37
  ).toEqual(["check", "init", "refresh"]);
38
+ expect(
39
+ subcommand(program, "software")
40
+ .commands.map((c) => c.name())
41
+ .sort(),
42
+ ).toEqual(["build"]);
43
+ });
44
+
45
+ it("software build exposes the three knobs + use-published with env bindings", () => {
46
+ const build = subcommand(subcommand(program, "software"), "build");
47
+ const names = longNames(build);
48
+ for (const f of ["--channel", "--variant", "--location", "--use-published"]) {
49
+ expect(names).toContain(f);
50
+ }
51
+ const env = (long: string) => build.options.find((o) => o.long === long)?.envVar;
52
+ expect(env("--channel")).toBe("PL_BUILD_CHANNEL");
53
+ expect(env("--variant")).toBe("PL_BUILD_VARIANT");
54
+ expect(env("--location")).toBe("PL_BUILD_LOCATION");
55
+ expect(env("--use-published")).toBe("PL_BUILD_USE_PUBLISHED");
37
56
  });
38
57
 
39
58
  it("preserves key flags + env bindings", () => {
package/src/cli.ts CHANGED
@@ -12,6 +12,7 @@ import { uploadPackageV1Command } from "./cmd/upload-package-v1";
12
12
  import { structureCheckCommand } from "./cmd/structure/check";
13
13
  import { structureInitCommand } from "./cmd/structure/init";
14
14
  import { structureRefreshCommand } from "./cmd/structure/refresh";
15
+ import { softwareCommand } from "./cmd/software";
15
16
 
16
17
  // `packageRoot` is the package install root (the dir containing `src/` and
17
18
  // `bin/`); the `structure` commands resolve their templates under it. bin/run.js
@@ -32,6 +33,7 @@ export function buildProgram(packageRoot: string = process.cwd()): Command {
32
33
  program.addCommand(listOverviewSnapshotsCommand());
33
34
  program.addCommand(restoreOverviewFromSnapshotCommand());
34
35
  program.addCommand(uploadPackageV1Command());
36
+ program.addCommand(softwareCommand());
35
37
 
36
38
  // `structure` subcommands: check | init | refresh.
37
39
  const structure = new Command("structure").description(
@@ -0,0 +1,175 @@
1
+ import { Command, Option } from "commander";
2
+ import { util, envs, createBuilder } from "@platforma-sdk/package-builder-lib";
3
+ import { parseScenario, channels, variants, locations } from "./knobs";
4
+ import { runBuild } from "./run-build";
5
+ import {
6
+ PL_BUILD_CHANNEL,
7
+ PL_BUILD_VARIANT,
8
+ PL_BUILD_LOCATION,
9
+ PL_BUILD_USE_PUBLISHED,
10
+ } from "./env";
11
+
12
+ // Build a software module in one pass — build, push when remote, then write the descriptor
13
+ // last. With no knobs it reproduces `pl-pkg build`.
14
+ export function softwareBuildCommand(): Command {
15
+ const cmd = new Command("build").description(
16
+ "Build a software module for a target and write its .sw.json descriptor (build + push in one pass)",
17
+ );
18
+
19
+ // The three target knobs (env-backed; flag wins on conflict).
20
+ cmd.addOption(
21
+ new Option("--channel <channel>", "registry channel to build for")
22
+ .choices([...channels])
23
+ .env(PL_BUILD_CHANNEL),
24
+ );
25
+ cmd.addOption(
26
+ new Option("--variant <variant>", "artifact kind to build (default: all declared)")
27
+ .choices([...variants])
28
+ .env(PL_BUILD_VARIANT),
29
+ );
30
+ cmd.addOption(
31
+ new Option("--location <location>", "where the artifact lands (local = no push)")
32
+ .choices([...locations])
33
+ .env(PL_BUILD_LOCATION),
34
+ );
35
+ cmd.addOption(
36
+ new Option(
37
+ "--use-published",
38
+ "do not build or push; emit a descriptor pointing at the already-published release artifact",
39
+ ).env(PL_BUILD_USE_PUBLISHED),
40
+ );
41
+
42
+ // Pass-through build options, mirroring `pl-pkg build` so this is a drop-in replacement.
43
+ cmd.addOption(
44
+ new Option("--log-level <level>", "logging level")
45
+ .choices(["error", "warn", "info", "debug"])
46
+ .default("info"),
47
+ );
48
+ cmd.addOption(new Option("--package-root <path>", "path to directory with package.json file"));
49
+ cmd.addOption(
50
+ new Option("--force", "force action, ignoring automatic safety checks").default(false),
51
+ );
52
+ cmd.addOption(new Option("--version <value>", "override package version (ignore package.json)"));
53
+ cmd.addOption(
54
+ new Option("--platform <os-arch>", "{os}-{arch} pair; no effect on cross-platform packages")
55
+ .choices([...util.AllPlatforms])
56
+ .env(envs.PL_PKG_OS),
57
+ );
58
+ cmd.addOption(new Option("--all-platforms", "build for all supported platforms").default(false));
59
+ cmd.addOption(
60
+ new Option("--full-dir-hash", "hash file contents (not metadata) for the dev content hash")
61
+ .env(envs.PL_PKG_FULL_HASH)
62
+ .default(false),
63
+ );
64
+ cmd.addOption(new Option("--package-id <id...>", "act only on selected packages"));
65
+ cmd.addOption(
66
+ new Option("--content-root <path>", "override software content root").env(
67
+ envs.PL_PKG_CONTENT_ROOT,
68
+ ),
69
+ );
70
+ cmd.addOption(
71
+ new Option("--storage-url <url>", "override binary upload registry URL").env(
72
+ envs.PL_PKG_STORAGE_URL,
73
+ ),
74
+ );
75
+ cmd.addOption(
76
+ new Option(
77
+ "--docker-registry <registry>",
78
+ "docker pull registry embedded in the descriptor",
79
+ ).env(envs.PL_DOCKER_REGISTRY),
80
+ );
81
+ cmd.addOption(
82
+ new Option("--docker-push-to <registry>", "alternative registry for docker push").env(
83
+ envs.PL_DOCKER_REGISTRY_PUSH_TO,
84
+ ),
85
+ );
86
+ cmd.addOption(
87
+ new Option("--docker-build", "build docker images").env(envs.PL_DOCKER_BUILD).default(false),
88
+ );
89
+ cmd.addOption(
90
+ new Option("--docker-no-build", "do not build docker images")
91
+ .env(envs.PL_DOCKER_NO_BUILD)
92
+ .default(false),
93
+ );
94
+ cmd.addOption(
95
+ new Option("--docker-autopush", "push docker images after build")
96
+ .env(envs.PL_DOCKER_AUTOPUSH)
97
+ .default(false),
98
+ );
99
+ cmd.addOption(
100
+ new Option("--docker-no-autopush", "do not push docker images")
101
+ .env(envs.PL_DOCKER_NO_AUTOPUSH)
102
+ .default(false),
103
+ );
104
+ cmd.addOption(
105
+ new Option("--conda-build", "build conda environment before packing")
106
+ .env(envs.PL_CONDA_BUILD)
107
+ .default(false),
108
+ );
109
+ cmd.addOption(
110
+ new Option("--conda-no-build", "do not build conda environment")
111
+ .env(envs.PL_CONDA_NO_BUILD)
112
+ .default(false),
113
+ );
114
+
115
+ cmd.action(async (o) => {
116
+ const logger = util.createLogger(o.logLevel);
117
+
118
+ try {
119
+ const scenario = parseScenario({
120
+ channel: o.channel,
121
+ variant: o.variant,
122
+ location: o.location,
123
+ usePublished: Boolean(o.usePublished),
124
+ });
125
+
126
+ const core = createBuilder(logger, { packageRoot: o.packageRoot });
127
+ core.version = o.version;
128
+ core.targetPlatform = o.platform as util.PlatformType;
129
+ core.allPlatforms = Boolean(o.allPlatforms);
130
+ core.fullDirHash = Boolean(o.fullDirHash);
131
+
132
+ // Collapse the pl-pkg-parity --{docker,conda}-{,no-}* flag pairs to decisions here, at the CLI
133
+ // boundary, so runBuild receives resolved booleans (docker defaults to on in CI, push unless private).
134
+ const buildDocker = util.shouldDoAction({
135
+ default: envs.isCI(),
136
+ enable: o.dockerBuild,
137
+ disable: o.dockerNoBuild,
138
+ });
139
+ const pushDocker =
140
+ buildDocker &&
141
+ util.shouldDoAction({
142
+ default: envs.isCI() && !core.isPrivate,
143
+ enable: o.dockerAutopush,
144
+ disable: o.dockerNoAutopush,
145
+ });
146
+
147
+ await runBuild({
148
+ core,
149
+ scenario,
150
+ logger,
151
+ options: {
152
+ ids: o.packageId,
153
+ force: o.force,
154
+ contentRoot: o.contentRoot,
155
+ storageUrl: o.storageUrl,
156
+ dockerRegistry: o.dockerRegistry,
157
+ dockerPushTo: o.dockerPushTo,
158
+ buildDocker,
159
+ pushDocker,
160
+ condaBuild: util.shouldDoAction({
161
+ default: true,
162
+ enable: o.condaBuild,
163
+ disable: o.condaNoBuild,
164
+ }),
165
+ },
166
+ });
167
+ } catch (e) {
168
+ logger.debug(e);
169
+ if (e instanceof Error) logger.debug(e.stack);
170
+ throw e;
171
+ }
172
+ });
173
+
174
+ return cmd;
175
+ }
@@ -0,0 +1,92 @@
1
+ import { describe, it, expect, afterEach, vi } from "vitest";
2
+ import { defaults } from "@platforma-sdk/package-builder-lib";
3
+
4
+ // Mock the AWS SDK client and docker CLI so nothing touches the network or a real docker daemon.
5
+ const { sendMock, spawnMock } = vi.hoisted(() => ({ sendMock: vi.fn(), spawnMock: vi.fn() }));
6
+ vi.mock("@aws-sdk/client-ecr-public", () => ({
7
+ ECRPUBLICClient: class {
8
+ send = sendMock;
9
+ },
10
+ GetAuthorizationTokenCommand: class {},
11
+ }));
12
+ vi.mock("node:child_process", () => ({ spawnSync: spawnMock }));
13
+
14
+ const { ensureAwsProfile, ensureEcrLogin } = await import("./ecr-login");
15
+
16
+ const DEV_ECR = "public.ecr.aws/miresearch/pl-containers";
17
+ const token = (raw: string) => Buffer.from(raw, "utf8").toString("base64");
18
+ const authOk = (raw: string) => ({ authorizationData: { authorizationToken: token(raw) } });
19
+
20
+ afterEach(() => {
21
+ for (const k of ["AWS_ACCESS_KEY_ID", "AWS_PROFILE", "PL_AWS_PROFILE"]) delete process.env[k];
22
+ vi.clearAllMocks();
23
+ });
24
+
25
+ describe("ensureAwsProfile — credential-chain selection", () => {
26
+ it("does nothing when env credentials are present (the CI path)", () => {
27
+ process.env.AWS_ACCESS_KEY_ID = "AKIA...";
28
+ ensureAwsProfile();
29
+ expect(process.env.AWS_PROFILE).toBeUndefined();
30
+ });
31
+
32
+ it("leaves an explicit AWS_PROFILE untouched", () => {
33
+ process.env.AWS_PROFILE = "my-profile";
34
+ ensureAwsProfile();
35
+ expect(process.env.AWS_PROFILE).toBe("my-profile");
36
+ });
37
+
38
+ it("prefers PL_AWS_PROFILE over the built-in default", () => {
39
+ process.env.PL_AWS_PROFILE = "custom-dev";
40
+ ensureAwsProfile();
41
+ expect(process.env.AWS_PROFILE).toBe("custom-dev");
42
+ });
43
+
44
+ it("falls back to the built-in dev profile when nothing is set", () => {
45
+ ensureAwsProfile();
46
+ expect(process.env.AWS_PROFILE).toBe(defaults.AWS_DEV_PROFILE);
47
+ });
48
+ });
49
+
50
+ describe("ensureEcrLogin", () => {
51
+ it("rejects a non-public.ecr.aws host", async () => {
52
+ await expect(ensureEcrLogin("quay.io/milaboratories/pl")).rejects.toThrow(/public\.ecr\.aws/);
53
+ expect(sendMock).not.toHaveBeenCalled();
54
+ });
55
+
56
+ it("logs in with the decoded token, splitting only on the first colon", async () => {
57
+ sendMock.mockResolvedValue(authOk("AWS:pass:with:colons"));
58
+ spawnMock.mockReturnValue({ status: 0 });
59
+
60
+ await ensureEcrLogin(DEV_ECR);
61
+
62
+ expect(spawnMock).toHaveBeenCalledWith(
63
+ "docker",
64
+ ["login", "--username", "AWS", "--password-stdin", "public.ecr.aws"],
65
+ expect.objectContaining({ input: "pass:with:colons" }),
66
+ );
67
+ });
68
+
69
+ it("throws a recoverable SSO hint when the token is empty (expired/absent session)", async () => {
70
+ sendMock.mockResolvedValue({ authorizationData: { authorizationToken: undefined } });
71
+ await expect(ensureEcrLogin(DEV_ECR)).rejects.toThrow(/aws sso login/);
72
+ expect(spawnMock).not.toHaveBeenCalled();
73
+ });
74
+
75
+ it("throws on a malformed token with no ':' separator", async () => {
76
+ sendMock.mockResolvedValue(authOk("no-colon-here"));
77
+ await expect(ensureEcrLogin(DEV_ECR)).rejects.toThrow(/aws sso login/);
78
+ expect(spawnMock).not.toHaveBeenCalled();
79
+ });
80
+
81
+ it("throws when `docker login` exits non-zero", async () => {
82
+ sendMock.mockResolvedValue(authOk("AWS:pw"));
83
+ spawnMock.mockReturnValue({ status: 1 });
84
+ await expect(ensureEcrLogin(DEV_ECR)).rejects.toThrow(/docker login.*failed/);
85
+ });
86
+
87
+ it("throws when spawning docker errors", async () => {
88
+ sendMock.mockResolvedValue(authOk("AWS:pw"));
89
+ spawnMock.mockReturnValue({ error: new Error("ENOENT"), status: null });
90
+ await expect(ensureEcrLogin(DEV_ECR)).rejects.toThrow(/docker login.*failed/);
91
+ });
92
+ });
@@ -0,0 +1,75 @@
1
+ import { spawnSync } from "node:child_process";
2
+ import * as os from "node:os";
3
+ import {
4
+ ECRPUBLICClient as EcrPublicClient,
5
+ GetAuthorizationTokenCommand,
6
+ } from "@aws-sdk/client-ecr-public";
7
+ import { util, defaults, type Logger } from "@platforma-sdk/package-builder-lib";
8
+
9
+ const PUBLIC_ECR_HOST = "public.ecr.aws";
10
+ // ECR Public's API is served only from us-east-1, independent of where images are pulled.
11
+ const PUBLIC_ECR_REGION = "us-east-1";
12
+
13
+ // Pin AWS_PROFILE once so the ECR client and the S3 upload resolve identical credentials.
14
+ // Env credentials (CI) and an explicit AWS_PROFILE take precedence and skip this.
15
+ export function ensureAwsProfile(logger?: Logger): void {
16
+ if (process.env.AWS_ACCESS_KEY_ID || process.env.AWS_PROFILE) return;
17
+ const profile = process.env.PL_AWS_PROFILE ?? defaults.AWS_DEV_PROFILE;
18
+ process.env.AWS_PROFILE = profile;
19
+ logger?.debug(`Using AWS profile '${profile}' for dev push credentials`);
20
+ }
21
+
22
+ // Host of a scheme-less registry ref: public.ecr.aws/u5p1x5q2/pl-containers -> public.ecr.aws
23
+ function registryHost(registry: string): string {
24
+ return new URL(`https://${registry}`).hostname;
25
+ }
26
+
27
+ function ssoLoginHint(): string {
28
+ const profile = process.env.AWS_PROFILE ?? process.env.PL_AWS_PROFILE ?? defaults.AWS_DEV_PROFILE;
29
+ return ` aws sso login --profile ${profile}`;
30
+ }
31
+
32
+ // docker login to public ECR with an SDK-fetched token. No cached-auth check — re-authenticates
33
+ // every call, so an expired/absent session fails here with a recoverable hint, not opaquely at push.
34
+ export async function ensureEcrLogin(registry: string, logger?: Logger): Promise<void> {
35
+ const host = registryHost(registry);
36
+ if (host !== PUBLIC_ECR_HOST) {
37
+ // Public ECR is the only dev docker registry; reject a mis-set ecr:// override.
38
+ throw util.CLIError(
39
+ `automatic docker login is only supported for '${PUBLIC_ECR_HOST}', not '${host}'.\n` +
40
+ `Log in manually, or drop the ecr:// scheme from the push target to opt out.`,
41
+ );
42
+ }
43
+
44
+ logger?.info(`Logging in to ECR registry '${host}'...`);
45
+
46
+ let user: string;
47
+ let password: string;
48
+ try {
49
+ const client = new EcrPublicClient({ region: PUBLIC_ECR_REGION });
50
+ const res = await client.send(new GetAuthorizationTokenCommand({}));
51
+ const token = res.authorizationData?.authorizationToken;
52
+ if (!token) throw new Error("empty authorization token");
53
+ // Token decodes to "AWS:<password>"; password may contain ':', so split on the first only.
54
+ const decoded = Buffer.from(token, "base64").toString("utf8");
55
+ const sep = decoded.indexOf(":");
56
+ if (sep < 0) throw new Error(`malformed authorization token (no ':' separator)`);
57
+ user = decoded.slice(0, sep);
58
+ password = decoded.slice(sep + 1);
59
+ } catch (e) {
60
+ throw util.CLIError(
61
+ `failed to obtain an ECR login token for '${host}': ${e instanceof Error ? e.message : String(e)}\n` +
62
+ `Your AWS/SSO session is likely expired or absent — log in and retry:\n` +
63
+ ssoLoginHint(),
64
+ );
65
+ }
66
+
67
+ const login = spawnSync("docker", ["login", "--username", user, "--password-stdin", host], {
68
+ input: password,
69
+ stdio: ["pipe", "inherit", "inherit"],
70
+ env: { ...process.env, HOME: process.env.HOME || os.homedir() },
71
+ });
72
+ if (login.error || login.status !== 0) {
73
+ throw util.CLIError(`'docker login ${host}' failed`);
74
+ }
75
+ }
@@ -0,0 +1,17 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { readFileSync } from "node:fs";
3
+ import { fileURLToPath } from "node:url";
4
+ import { softwareBuildCacheEnv } from "./env";
5
+
6
+ // The block's turbo build task must cache-key on every software-build env var, declared once in the
7
+ // structurer template. This guards against the template drifting from softwareBuildCacheEnv.
8
+ const turboPath = fileURLToPath(
9
+ new URL("../../structure/templates/static/root/turbo.json", import.meta.url),
10
+ );
11
+ const buildEnv: string[] = JSON.parse(readFileSync(turboPath, "utf8")).tasks.build.env;
12
+
13
+ describe("block turbo build cache env", () => {
14
+ it("declares every software-build cache var", () => {
15
+ for (const v of softwareBuildCacheEnv) expect(buildEnv).toContain(v);
16
+ });
17
+ });
@@ -0,0 +1,22 @@
1
+ import { envs } from "@platforma-sdk/package-builder-lib";
2
+
3
+ // The `software build` knob env vars (the flag/env pairs the scenario scripts set).
4
+ export const PL_BUILD_CHANNEL = "PL_BUILD_CHANNEL";
5
+ export const PL_BUILD_VARIANT = "PL_BUILD_VARIANT";
6
+ export const PL_BUILD_LOCATION = "PL_BUILD_LOCATION";
7
+ export const PL_BUILD_USE_PUBLISHED = "PL_BUILD_USE_PUBLISHED";
8
+
9
+ // Every env var that changes `block-tools software build` output, so must key the turbo build cache.
10
+ export const softwareBuildCacheEnv: readonly string[] = [
11
+ PL_BUILD_CHANNEL,
12
+ PL_BUILD_VARIANT,
13
+ PL_BUILD_LOCATION,
14
+ PL_BUILD_USE_PUBLISHED,
15
+ envs.PL_DOCKER_REGISTRY_PUSH_TO,
16
+ envs.PL_DEV_DOCKER_PUSH_URL,
17
+ envs.PL_DEV_DOCKER_PULL_URL,
18
+ envs.PL_DEV_BINARY_UPLOAD_URL,
19
+ envs.PL_RELEASE_DOCKER_PUSH_URL,
20
+ envs.PL_RELEASE_DOCKER_PULL_URL,
21
+ envs.PL_RELEASE_BINARY_UPLOAD_URL,
22
+ ];
@@ -0,0 +1,9 @@
1
+ import { Command } from "commander";
2
+ import { softwareBuildCommand } from "./build";
3
+
4
+ // `software` parent command: per-target software build (and future software subcommands).
5
+ export function softwareCommand(): Command {
6
+ const cmd = new Command("software").description("Build and publish a block's software modules");
7
+ cmd.addCommand(softwareBuildCommand());
8
+ return cmd;
9
+ }
@@ -0,0 +1,52 @@
1
+ import { describe, it, expect } from "vitest";
2
+ import { parseScenario } from "./knobs";
3
+
4
+ describe("parseScenario — knobs collapse to the supported set", () => {
5
+ it("no location => bare invocation (pl-pkg parity)", () => {
6
+ expect(parseScenario({})).toEqual({ kind: "bare" });
7
+ expect(parseScenario({ channel: "dev" })).toEqual({ kind: "bare" });
8
+ });
9
+
10
+ it("explicit location => a target cell carrying the knobs as enums", () => {
11
+ expect(parseScenario({ channel: "dev", variant: "binary", location: "remote" })).toEqual({
12
+ kind: "target",
13
+ channel: "dev",
14
+ variant: "binary",
15
+ location: "remote",
16
+ });
17
+ // channel and variant default when unset.
18
+ expect(parseScenario({ location: "local" })).toEqual({
19
+ kind: "target",
20
+ channel: "release",
21
+ variant: "all",
22
+ location: "local",
23
+ });
24
+ });
25
+
26
+ it("variant=all is preserved on the target", () => {
27
+ expect(parseScenario({ channel: "dev", variant: "all", location: "local" })).toEqual({
28
+ kind: "target",
29
+ channel: "dev",
30
+ variant: "all",
31
+ location: "local",
32
+ });
33
+ });
34
+
35
+ it("variant=none => no-software, regardless of location", () => {
36
+ expect(parseScenario({ channel: "dev", variant: "none", location: "local" })).toEqual({
37
+ kind: "no-software",
38
+ });
39
+ expect(parseScenario({ variant: "none" })).toEqual({ kind: "no-software" });
40
+ });
41
+
42
+ it("use-published collapses everything but rejects docker and all (binary-only)", () => {
43
+ expect(parseScenario({ channel: "dev", usePublished: true })).toEqual({
44
+ kind: "binary-existing",
45
+ });
46
+ expect(parseScenario({ usePublished: true, variant: "binary" })).toEqual({
47
+ kind: "binary-existing",
48
+ });
49
+ expect(() => parseScenario({ usePublished: true, variant: "docker" })).toThrow(/binary-only/);
50
+ expect(() => parseScenario({ usePublished: true, variant: "all" })).toThrow(/binary-only/);
51
+ });
52
+ });