@akanjs/devkit 3.1.0-rc.1 → 3.1.0-rc.2

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 (66) hide show
  1. package/akanConfig/akanConfig.test.ts +51 -5
  2. package/akanConfig/akanConfig.ts +31 -12
  3. package/akanConfig/akanWorkspaceConfig.test.ts +6 -6
  4. package/akanConfig/akanWorkspaceConfig.ts +1 -1
  5. package/applicationBuildRunner.test.ts +33 -1
  6. package/applicationBuildRunner.ts +4 -5
  7. package/cloud/cloudApi.ts +22 -15
  8. package/cloud/cloudHttpError.test.ts +32 -0
  9. package/cloud/cloudHttpError.ts +54 -0
  10. package/cloud/cloudUpdates.ts +138 -0
  11. package/cloud/globalConfig.test.ts +17 -0
  12. package/cloud/globalConfig.ts +8 -2
  13. package/cloud/index.ts +3 -0
  14. package/cloud/pipelineSecrets.ts +114 -0
  15. package/cloud/updatePackUpload.test.ts +100 -45
  16. package/cloud/updatePackUpload.ts +38 -18
  17. package/commandDecorators/argMeta.ts +11 -2
  18. package/commandDecorators/command.ts +40 -6
  19. package/commandDecorators/commandBuilder.ts +6 -1
  20. package/commandDecorators/commandDecorators.test.ts +205 -4
  21. package/commandDecorators/exitCodeError.ts +15 -0
  22. package/commandDecorators/index.ts +1 -0
  23. package/executors.test.ts +46 -0
  24. package/executors.ts +26 -23
  25. package/frontendBuild/csrArtifactBuilder.ts +18 -1
  26. package/incrementalBuilder/buildBatch.proc.ts +12 -7
  27. package/incrementalBuilder/incrementalBuilder.proc.test.ts +63 -0
  28. package/incrementalBuilder/incrementalBuilder.proc.ts +7 -5
  29. package/mobile/desktopServerStage.test.ts +13 -0
  30. package/mobile/desktopServerStage.ts +5 -7
  31. package/mobile/index.ts +2 -0
  32. package/mobile/nativeApi.test.ts +26 -0
  33. package/mobile/nativeApi.ts +5 -2
  34. package/mobile/nativeApp.test.ts +135 -33
  35. package/mobile/nativeApp.ts +109 -73
  36. package/mobile/nativeConfig.ts +2 -1
  37. package/mobile/nativePublicEnv.test.ts +131 -0
  38. package/mobile/nativePublicEnv.ts +116 -0
  39. package/mobile/serverBundleSecrets.test.ts +79 -0
  40. package/mobile/serverBundleSecrets.ts +60 -16
  41. package/mobile/updatePackFolder.test.ts +98 -0
  42. package/mobile/updatePackFolder.ts +131 -0
  43. package/mobile/updateUploader.test.ts +114 -22
  44. package/mobile/updateUploader.ts +75 -8
  45. package/package.json +2 -2
  46. package/pipeline/PipelineCompiler.test.ts +157 -1
  47. package/pipeline/PipelineCompiler.ts +80 -46
  48. package/pipeline/PipelineDefinition.test.ts +9 -0
  49. package/pipeline/PipelineDefinition.ts +4 -3
  50. package/pipeline/PipelineDryRun.test.ts +27 -0
  51. package/pipeline/PipelineDryRun.ts +2 -2
  52. package/pipeline/PipelineInputReader.ts +157 -253
  53. package/pipeline/PipelineRules.ts +92 -0
  54. package/pipeline/index.ts +1 -0
  55. package/qualityScanner.test.ts +5 -3
  56. package/slicePlanner.ts +5 -3
  57. package/subspace.test.ts +90 -19
  58. package/subspace.ts +52 -500
  59. package/subspaceClone.ts +361 -0
  60. package/subspaceConfig.ts +1 -2
  61. package/subspaceReport.ts +162 -0
  62. package/transforms/serverEnvPlugin.test.ts +76 -0
  63. package/transforms/serverEnvPlugin.ts +9 -25
  64. package/transforms/serverEnvProfile.test.ts +189 -0
  65. package/transforms/serverEnvProfile.ts +324 -60
  66. package/typecheck/projectTypecheck.ts +6 -5
@@ -1,122 +1,62 @@
1
1
  import {
2
- type PipelineActionName,
3
2
  type PipelineDiagnostic,
4
- type PipelineMilestone,
5
3
  PipelineNames,
6
- type PipelineOtaPlatform,
7
4
  PipelinePattern,
8
5
  type PipelinePlanRetry,
9
- type PipelineTrigger,
6
+ type PipelineValueProblem,
7
+ PipelineValueReader,
10
8
  pipelineActionNames,
11
9
  pipelineMilestones,
12
10
  pipelineOtaPlatforms,
13
11
  pipelinePlanLimits,
14
12
  pipelineTriggers,
15
13
  } from "akanjs/common";
14
+ import type {
15
+ PipelineActionStageRule,
16
+ PipelineAppRule,
17
+ PipelineAppStageRule,
18
+ PipelineMatrixStageRule,
19
+ PipelineParallelStageRule,
20
+ PipelinePostRule,
21
+ PipelineShellStageRule,
22
+ PipelineStepRule,
23
+ PipelineWhenRule,
24
+ PipelineWorkspaceRule,
25
+ PipelineWorkspaceStageRule,
26
+ } from "./PipelineRules";
16
27
 
17
- export interface PipelineWhenRule {
18
- branch: string[] | null;
19
- trigger: PipelineTrigger[] | null;
20
- changed: string[] | null;
21
- /** A workspace `matrix: "apps"` stage's narrowing; never set elsewhere. */
22
- app: string[] | null;
23
- }
24
-
25
- export interface PipelineStepRule {
26
- localId: string;
27
- name: string;
28
- timeoutSeconds: number | null;
29
- retry: PipelinePlanRetry | null;
30
- allowFailure: boolean;
31
- run: string;
32
- cwd: string;
33
- /** The stage's env under the step's own. */
34
- env: Record<string, string>;
35
- secrets: string[];
36
- skipExitCodes: number[];
37
- }
38
-
39
- interface PipelineStageRuleBase {
40
- id: string;
41
- name: string;
42
- description: string | null;
43
- milestone: PipelineMilestone | null;
44
- when: PipelineWhenRule;
45
- timeoutSeconds: number | null;
46
- allowFailure: boolean;
47
- path: string;
48
- }
49
-
50
- export interface PipelineShellStageRule extends PipelineStageRuleBase {
51
- kind: "sh";
52
- steps: PipelineStepRule[];
53
- }
54
-
55
- export interface PipelineActionStageRule extends PipelineStageRuleBase {
56
- kind: "action";
57
- action: PipelineActionName;
58
- named: boolean;
59
- retry: PipelinePlanRetry | null;
60
- /** `ota` only; `null` takes the default. */
61
- targets: string[] | null;
62
- platforms: PipelineOtaPlatform[] | null;
63
- }
64
-
65
- export interface PipelineParallelStageRule extends PipelineStageRuleBase {
66
- kind: "parallel";
67
- maxParallel: number | null;
68
- failFast: boolean;
69
- branches: PipelineShellStageRule[];
70
- }
71
-
72
- export interface PipelineMatrixStageRule extends PipelineStageRuleBase {
73
- kind: "matrix";
74
- maxParallel: number | null;
75
- steps: PipelineStepRule[];
76
- }
77
-
78
- export type PipelineWorkspaceStageRule = PipelineShellStageRule | PipelineParallelStageRule | PipelineMatrixStageRule;
79
- export type PipelineAppStageRule = PipelineShellStageRule | PipelineActionStageRule;
80
-
81
- export interface PipelinePostRule {
82
- success: PipelineStepRule[];
83
- failure: PipelineStepRule[];
84
- canceled: PipelineStepRule[];
85
- always: PipelineStepRule[];
86
- }
87
-
88
- export interface PipelineWorkspaceRule {
89
- env: Record<string, string>;
90
- timeoutSeconds: number | null;
91
- before: PipelineWorkspaceStageRule[];
92
- after: PipelineWorkspaceStageRule[];
93
- post: PipelinePostRule;
94
- }
95
-
96
- export interface PipelineAppRule {
97
- env: Record<string, string>;
98
- after: string[];
99
- stages: PipelineAppStageRule[];
100
- }
101
-
102
- type Scope = "workspace" | "app";
103
28
  type JsonObject = Record<string, unknown>;
104
29
 
105
30
  /** Turns one file's `pipeline` value into rules, reporting what is wrong with it rather than throwing. */
106
31
  export class PipelineInputReader {
107
32
  static readonly #futureKeys = ["cache", "artifacts", "input", "image", "services", "notify"];
33
+ static readonly #stageKeys = ["id", "name", "description", "milestone", "when", "timeout", "allowFailure"];
34
+ static readonly #shellKeys = [...PipelineInputReader.#stageKeys, "sh", "steps", "env", "secrets", "retry"];
35
+ static readonly #postResults: readonly string[] = ["success", "failure", "canceled", "always"];
108
36
  static readonly #durationPattern = /^(\d+)(s|m|h)$/;
37
+ static readonly #trailingHighSurrogate = /[\uD800-\uDBFF]$/;
109
38
  static readonly #stepNameChars = 80;
39
+ static readonly #shownChars = 80;
40
+
41
+ static clip(text: string, max: number) {
42
+ if (text.length <= max) return text;
43
+ const cut = text.slice(0, max - 1);
44
+ return `${PipelineInputReader.#trailingHighSurrogate.test(cut) ? cut.slice(0, -1) : cut}…`;
45
+ }
110
46
 
111
47
  readonly diagnostics: PipelineDiagnostic[] = [];
112
48
  readonly #file: string;
49
+ readonly #read = new PipelineValueReader<null>("config", (path, ...problems) => {
50
+ for (const problem of problems) this.#problem(path, problem);
51
+ return null;
52
+ });
113
53
 
114
54
  constructor(file: string) {
115
55
  this.#file = file;
116
56
  }
117
57
 
118
58
  workspace(value: unknown): PipelineWorkspaceRule | null {
119
- const input = this.#object(value, "pipeline", ["schemaVersion", "env", "timeout", "before", "after", "post"]);
59
+ const input = this.#read.object(value, "pipeline", ["schemaVersion", "env", "timeout", "before", "after", "post"]);
120
60
  if (!input) return null;
121
61
  this.#schemaVersion(input.schemaVersion, "pipeline.schemaVersion");
122
62
  const ids = new Set<string>();
@@ -136,7 +76,7 @@ export class PipelineInputReader {
136
76
  }
137
77
 
138
78
  app(value: unknown): PipelineAppRule | null {
139
- const input = this.#object(value, "pipeline", ["schemaVersion", "stages", "env", "after"]);
79
+ const input = this.#read.object(value, "pipeline", ["schemaVersion", "stages", "env", "after"]);
140
80
  if (!input) return null;
141
81
  this.#schemaVersion(input.schemaVersion, "pipeline.schemaVersion");
142
82
  if (input.stages === undefined) this.#report("pipeline.schema.invalid", "lists no `stages`", "pipeline");
@@ -154,9 +94,9 @@ export class PipelineInputReader {
154
94
  }
155
95
 
156
96
  #workspaceStage(value: unknown, path: string): PipelineWorkspaceStageRule | null {
157
- const raw = this.#object(value, path);
97
+ const raw = this.#read.object(value, path);
158
98
  if (!raw) return null;
159
- if ("use" in raw) {
99
+ if (this.#declares(raw, "use")) {
160
100
  this.#report(
161
101
  "pipeline.schema.invalid",
162
102
  "built-in actions are declared per app, in apps/<app>/akan.config.ts",
@@ -164,49 +104,50 @@ export class PipelineInputReader {
164
104
  );
165
105
  return null;
166
106
  }
167
- if ("parallel" in raw) return this.#parallelStage(raw, path);
168
- if ("matrix" in raw) return this.#matrixStage(raw, path);
169
- return this.#shellStage(raw, path, "workspace");
107
+ if (this.#declares(raw, "parallel")) return this.#parallelStage(raw, path);
108
+ if (this.#declares(raw, "matrix")) return this.#matrixStage(raw, path);
109
+ return this.#shellStage(raw, path);
170
110
  }
171
111
 
172
112
  #appStage(value: unknown, path: string): PipelineAppStageRule | null {
173
- const raw = this.#object(value, path);
113
+ const raw = this.#read.object(value, path);
174
114
  if (!raw) return null;
175
- if ("parallel" in raw || "matrix" in raw) {
115
+ const workspaceKey = this.#declares(raw, "parallel") ? "parallel" : this.#declares(raw, "matrix") ? "matrix" : null;
116
+ if (workspaceKey) {
176
117
  this.#report(
177
118
  "pipeline.schema.invalid",
178
- `an app's own stage takes no \`${"parallel" in raw ? "parallel" : "matrix"}\`: it is already that app's, and parallel or per-app copies belong to the workspace root akan.config.ts`,
119
+ `an app's own stage takes no \`${workspaceKey}\`: it is already that app's, and parallel or per-app copies belong to the workspace root akan.config.ts`,
179
120
  path,
180
121
  );
181
122
  return null;
182
123
  }
183
- if ("use" in raw) return this.#actionStage(raw, path);
184
- return this.#shellStage(raw, path, "app");
124
+ if (this.#declares(raw, "use")) return this.#actionStage(raw, path);
125
+ return this.#shellStage(raw, path);
126
+ }
127
+
128
+ //? `key: undefined` is the key left out, but on a stage without `sh` or `steps` it still tells which kind was meant.
129
+ #declares(raw: JsonObject, key: "use" | "parallel" | "matrix") {
130
+ return raw[key] !== undefined || (key in raw && raw.sh === undefined && raw.steps === undefined);
185
131
  }
186
132
 
187
133
  #actionStage(raw: JsonObject, path: string): PipelineActionStageRule | null {
188
134
  if (
189
- !this.#keys(raw, path, [
135
+ !this.#read.keys(raw, path, [
190
136
  "use",
191
- "id",
192
- "name",
193
- "description",
194
- "milestone",
195
- "when",
196
- "timeout",
197
- "allowFailure",
137
+ ...PipelineInputReader.#stageKeys,
198
138
  "retry",
199
139
  ...(raw.use === "ota" ? ["targets", "platforms"] : []),
200
140
  ])
201
141
  )
202
142
  return null;
203
- const action = this.#oneOf(raw.use, `${path}.use`, pipelineActionNames);
143
+ const action = this.#read.oneOf(raw.use, `${path}.use`, pipelineActionNames);
204
144
  if (!action) return null;
205
- const name = raw.name === undefined ? null : this.#text(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
145
+ const name =
146
+ raw.name === undefined ? null : this.#read.string(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
206
147
  const id = raw.id === undefined ? action : this.#id(raw.id, `${path}.id`);
207
148
  if (!id) return null;
208
149
  const attempts =
209
- raw.retry === undefined ? null : this.#int(raw.retry, `${path}.retry`, 1, pipelinePlanLimits.retryAttempts);
150
+ raw.retry === undefined ? null : this.#read.int(raw.retry, `${path}.retry`, 1, pipelinePlanLimits.retryAttempts);
210
151
  return {
211
152
  kind: "action",
212
153
  action,
@@ -215,7 +156,7 @@ export class PipelineInputReader {
215
156
  named: name !== null,
216
157
  description: this.#description(raw.description, `${path}.description`),
217
158
  milestone: this.#milestone(raw.milestone, `${path}.milestone`),
218
- when: this.#when(raw.when, `${path}.when`, false),
159
+ when: this.#when(raw.when, `${path}.when`),
219
160
  timeoutSeconds: this.#duration(raw.timeout, `${path}.timeout`),
220
161
  allowFailure: this.#boolean(raw.allowFailure, `${path}.allowFailure`),
221
162
  retry: attempts === null ? null : { attempts, delaySeconds: 0 },
@@ -228,7 +169,7 @@ export class PipelineInputReader {
228
169
  ? null
229
170
  : this.#nonEmptySet(
230
171
  this.#list(raw.platforms, `${path}.platforms`).flatMap((platform, idx) => {
231
- const parsed = this.#oneOf(platform, `${path}.platforms[${idx}]`, pipelineOtaPlatforms);
172
+ const parsed = this.#read.oneOf(platform, `${path}.platforms[${idx}]`, pipelineOtaPlatforms);
232
173
  return parsed ? [parsed] : [];
233
174
  }),
234
175
  `${path}.platforms`,
@@ -245,55 +186,25 @@ export class PipelineInputReader {
245
186
  return [...new Set(items)];
246
187
  }
247
188
 
248
- #shellStage(raw: JsonObject, path: string, scope: Scope): PipelineShellStageRule | null {
249
- const keys = [
250
- "id",
251
- "name",
252
- "description",
253
- "milestone",
254
- "when",
255
- "timeout",
256
- "allowFailure",
257
- "sh",
258
- "steps",
259
- "env",
260
- "secrets",
261
- "retry",
262
- ];
263
- if (!this.#keys(raw, path, keys)) return null;
264
- const head = this.#stageHead(raw, path, scope === "workspace" ? "workspace" : "app");
189
+ #shellStage(raw: JsonObject, path: string): PipelineShellStageRule | null {
190
+ if (!this.#read.keys(raw, path, PipelineInputReader.#shellKeys)) return null;
191
+ const head = this.#stageHead(raw, path);
265
192
  if (!head) return null;
266
193
  const steps = this.#stageSteps(raw, path);
267
194
  return steps ? { kind: "sh", ...head, steps } : null;
268
195
  }
269
196
 
270
197
  #matrixStage(raw: JsonObject, path: string): PipelineMatrixStageRule | null {
271
- const keys = [
272
- "id",
273
- "name",
274
- "description",
275
- "milestone",
276
- "when",
277
- "timeout",
278
- "allowFailure",
279
- "sh",
280
- "steps",
281
- "env",
282
- "secrets",
283
- "retry",
284
- "matrix",
285
- "maxParallel",
286
- ];
287
- if (!this.#keys(raw, path, keys)) return null;
198
+ if (!this.#read.keys(raw, path, [...PipelineInputReader.#shellKeys, "matrix", "maxParallel"])) return null;
288
199
  if (raw.matrix !== "apps") {
289
200
  this.#report(
290
201
  "pipeline.schema.invalid",
291
- `${JSON.stringify(raw.matrix)} is not a matrix axis: only "apps"`,
202
+ `${PipelineInputReader.#show(raw.matrix)} is not a matrix axis: only "apps"`,
292
203
  `${path}.matrix`,
293
204
  );
294
205
  return null;
295
206
  }
296
- const head = this.#stageHead(raw, path, "matrix");
207
+ const head = this.#stageHead(raw, path, { allowApp: true });
297
208
  if (!head) return null;
298
209
  const steps = this.#stageSteps(raw, path);
299
210
  if (!steps) return null;
@@ -301,35 +212,22 @@ export class PipelineInputReader {
301
212
  }
302
213
 
303
214
  #parallelStage(raw: JsonObject, path: string): PipelineParallelStageRule | null {
304
- const keys = [
305
- "id",
306
- "name",
307
- "description",
308
- "milestone",
309
- "when",
310
- "timeout",
311
- "allowFailure",
312
- "parallel",
313
- "maxParallel",
314
- "failFast",
315
- "env",
316
- "secrets",
317
- ];
318
- if (!this.#keys(raw, path, keys)) return null;
319
- const head = this.#stageHead(raw, path, "workspace");
215
+ const keys = [...PipelineInputReader.#stageKeys, "parallel", "maxParallel", "failFast", "env", "secrets"];
216
+ if (!this.#read.keys(raw, path, keys)) return null;
217
+ const head = this.#stageHead(raw, path);
320
218
  if (!head) return null;
321
219
  const env = this.#env(raw.env, `${path}.env`);
322
220
  const secrets = this.#secrets(raw.secrets, `${path}.secrets`);
323
221
  const ids = new Set<string>();
324
222
  const branches = this.#list(raw.parallel, `${path}.parallel`).flatMap((branch, idx) => {
325
223
  const branchPath = `${path}.parallel[${idx}]`;
326
- const branchRaw = this.#object(branch, branchPath);
224
+ const branchRaw = this.#read.object(branch, branchPath);
327
225
  if (!branchRaw) return [];
328
- if ("use" in branchRaw || "parallel" in branchRaw || "matrix" in branchRaw) {
226
+ if ((["use", "parallel", "matrix"] as const).some((key) => this.#declares(branchRaw, key))) {
329
227
  this.#report("pipeline.schema.invalid", "a parallel branch is a shell stage", branchPath);
330
228
  return [];
331
229
  }
332
- const rule = this.#shellStage(branchRaw, branchPath, "workspace");
230
+ const rule = this.#shellStage(branchRaw, branchPath);
333
231
  if (!rule) return [];
334
232
  if (rule.when.changed && head.when.changed) {
335
233
  this.#report(
@@ -339,6 +237,12 @@ export class PipelineInputReader {
339
237
  );
340
238
  return [];
341
239
  }
240
+ if (head.id === "post" && PipelineInputReader.#postResults.includes(rule.id))
241
+ this.#report(
242
+ "pipeline.id.reserved",
243
+ `"post.${rule.id}" is reserved for the steps of post.${rule.id}; give this branch or its parallel stage another \`id\``,
244
+ `${branchPath}.${branchRaw.id === undefined ? "name" : "id"}`,
245
+ );
342
246
  this.#claimId(ids, rule.id, rule.path);
343
247
  const steps = rule.steps.map((step) => ({
344
248
  ...step,
@@ -347,7 +251,7 @@ export class PipelineInputReader {
347
251
  }));
348
252
  return [{ ...rule, steps }];
349
253
  });
350
- if (!branches.length && Array.isArray(raw.parallel))
254
+ if (!branches.length && (raw.parallel === undefined || Array.isArray(raw.parallel)))
351
255
  this.#report("pipeline.schema.invalid", "a parallel stage holds at least one branch", `${path}.parallel`);
352
256
  return {
353
257
  kind: "parallel",
@@ -358,8 +262,8 @@ export class PipelineInputReader {
358
262
  };
359
263
  }
360
264
 
361
- #stageHead(raw: JsonObject, path: string, whenScope: "workspace" | "app" | "matrix") {
362
- const name = this.#text(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
265
+ #stageHead(raw: JsonObject, path: string, { allowApp = false }: { allowApp?: boolean } = {}) {
266
+ const name = this.#read.string(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
363
267
  if (name === null) return null;
364
268
  const id = raw.id === undefined ? this.#slugId(name, `${path}.name`) : this.#id(raw.id, `${path}.id`);
365
269
  if (!id) return null;
@@ -368,7 +272,7 @@ export class PipelineInputReader {
368
272
  name,
369
273
  description: this.#description(raw.description, `${path}.description`),
370
274
  milestone: this.#milestone(raw.milestone, `${path}.milestone`),
371
- when: this.#when(raw.when, `${path}.when`, whenScope === "matrix"),
275
+ when: this.#when(raw.when, `${path}.when`, { allowApp }),
372
276
  timeoutSeconds: this.#duration(raw.timeout, `${path}.timeout`),
373
277
  allowFailure: this.#boolean(raw.allowFailure, `${path}.allowFailure`),
374
278
  path,
@@ -407,11 +311,11 @@ export class PipelineInputReader {
407
311
  }
408
312
 
409
313
  #step(value: unknown, path: string, idx: number): PipelineStepRule | null {
410
- const raw = typeof value === "string" ? { sh: value } : this.#object(value, path);
314
+ const raw = typeof value === "string" ? { sh: value } : this.#read.object(value, path);
411
315
  if (!raw) return null;
412
316
  const keys = ["sh", "id", "name", "cwd", "env", "secrets", "timeout", "retry", "allowFailure", "skipExitCodes"];
413
- if (!this.#keys(raw, path, keys)) return null;
414
- const run = this.#text(raw.sh, typeof value === "string" ? path : `${path}.sh`, Number.POSITIVE_INFINITY);
317
+ if (!this.#read.keys(raw, path, keys)) return null;
318
+ const run = this.#read.string(raw.sh, typeof value === "string" ? path : `${path}.sh`, Number.POSITIVE_INFINITY);
415
319
  if (run === null) return null;
416
320
  if (new TextEncoder().encode(run).length > pipelinePlanLimits.shBytes) {
417
321
  this.#report("pipeline.limit.exceeded", `a script is at most ${pipelinePlanLimits.shBytes} bytes`, path);
@@ -420,7 +324,7 @@ export class PipelineInputReader {
420
324
  const name =
421
325
  raw.name === undefined
422
326
  ? PipelineInputReader.#nameOf(run)
423
- : this.#text(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
327
+ : this.#read.string(raw.name, `${path}.name`, pipelinePlanLimits.nameChars);
424
328
  if (name === null) return null;
425
329
  const localId =
426
330
  raw.id !== undefined
@@ -446,10 +350,10 @@ export class PipelineInputReader {
446
350
  #post(value: unknown, path: string): PipelinePostRule {
447
351
  const empty: PipelinePostRule = { success: [], failure: [], canceled: [], always: [] };
448
352
  if (value === undefined) return empty;
449
- const raw = this.#object(value, path);
353
+ const raw = this.#read.object(value, path);
450
354
  if (!raw) return empty;
451
355
  for (const key of Object.keys(raw))
452
- if (!["success", "failure", "canceled", "always"].includes(key))
356
+ if (!PipelineInputReader.#postResults.includes(key))
453
357
  this.#report(
454
358
  "pipeline.schema.unknownKey",
455
359
  key === "unstable"
@@ -473,10 +377,10 @@ export class PipelineInputReader {
473
377
  };
474
378
  }
475
379
 
476
- #when(value: unknown, path: string, allowApp: boolean): PipelineWhenRule {
380
+ #when(value: unknown, path: string, { allowApp = false }: { allowApp?: boolean } = {}): PipelineWhenRule {
477
381
  const none: PipelineWhenRule = { branch: null, trigger: null, changed: null, app: null };
478
382
  if (value === undefined) return none;
479
- const raw = this.#object(value, path);
383
+ const raw = this.#read.object(value, path);
480
384
  if (!raw) return none;
481
385
  for (const key of Object.keys(raw)) {
482
386
  if (["branch", "trigger", "changed"].includes(key) || (allowApp && key === "app")) continue;
@@ -493,10 +397,12 @@ export class PipelineInputReader {
493
397
  ? null
494
398
  : (typeof raw.trigger === "string" ? [raw.trigger] : this.#list(raw.trigger, `${path}.trigger`)).flatMap(
495
399
  (trigger, idx) => {
496
- const parsed = this.#oneOf(trigger, `${path}.trigger[${idx}]`, pipelineTriggers);
400
+ const parsed = this.#read.oneOf(trigger, `${path}.trigger[${idx}]`, pipelineTriggers);
497
401
  return parsed ? [parsed] : [];
498
402
  },
499
403
  );
404
+ if (Array.isArray(raw.trigger) && !raw.trigger.length)
405
+ this.#report("pipeline.schema.invalid", "a trigger list may not be empty", `${path}.trigger`);
500
406
  return {
501
407
  branch: this.#patterns(raw.branch, `${path}.branch`),
502
408
  trigger: triggers,
@@ -520,14 +426,14 @@ export class PipelineInputReader {
520
426
  if (value !== undefined && value !== 1)
521
427
  this.#report(
522
428
  "pipeline.schema.unsupported",
523
- `${JSON.stringify(value)} is not a schemaVersion this CLI reads (1)`,
429
+ `${PipelineInputReader.#show(value)} is not a schemaVersion this CLI reads (1)`,
524
430
  path,
525
431
  );
526
432
  }
527
433
 
528
434
  #env(value: unknown, path: string): Record<string, string> {
529
435
  if (value === undefined) return {};
530
- const raw = this.#object(value, path);
436
+ const raw = this.#read.object(value, path);
531
437
  if (!raw) return {};
532
438
  const env: Record<string, string> = {};
533
439
  for (const [name, entry] of Object.entries(raw)) {
@@ -535,6 +441,12 @@ export class PipelineInputReader {
535
441
  if (problem) this.#report("pipeline.env.name", problem, `${path}.${name}`);
536
442
  else if (typeof entry !== "string")
537
443
  this.#report("pipeline.schema.invalid", "must be a string", `${path}.${name}`);
444
+ else if (entry.length > pipelinePlanLimits.shBytes)
445
+ this.#report(
446
+ "pipeline.limit.exceeded",
447
+ `an env value is at most ${pipelinePlanLimits.shBytes} characters`,
448
+ `${path}.${name}`,
449
+ );
538
450
  else env[name] = entry;
539
451
  }
540
452
  return env;
@@ -542,9 +454,9 @@ export class PipelineInputReader {
542
454
 
543
455
  #secrets(value: unknown, path: string): string[] {
544
456
  const names = value === undefined ? [] : this.#strings(value, path);
545
- const valid = names.filter((name) => {
457
+ const valid = names.filter((name, idx) => {
546
458
  const problem = PipelineNames.secretNameError(name);
547
- if (problem) this.#report("pipeline.secret.name", problem, path);
459
+ if (problem) this.#report("pipeline.secret.name", problem, `${path}[${idx}]`);
548
460
  return !problem;
549
461
  });
550
462
  return [...new Set(valid)];
@@ -552,7 +464,7 @@ export class PipelineInputReader {
552
464
 
553
465
  #cwd(value: unknown, path: string) {
554
466
  if (value === undefined) return ".";
555
- const cwd = this.#text(value, path, 1024);
467
+ const cwd = this.#read.string(value, path, 1024);
556
468
  if (cwd === null) return ".";
557
469
  if (cwd.startsWith("/") || cwd.startsWith("~") || cwd.includes("\\") || cwd.split("/").includes("..")) {
558
470
  this.#report("pipeline.cwd.outside", `"${cwd}" is not a path inside the workspace`, path);
@@ -564,7 +476,7 @@ export class PipelineInputReader {
564
476
  #exitCodes(value: unknown, path: string): number[] {
565
477
  if (value === undefined) return [];
566
478
  const codes = this.#list(value, path).flatMap((code, idx) => {
567
- const parsed = this.#int(code, `${path}[${idx}]`, 1, 255);
479
+ const parsed = this.#read.int(code, `${path}[${idx}]`, 1, 255);
568
480
  return parsed === null ? [] : [parsed];
569
481
  });
570
482
  return [...new Set(codes)];
@@ -573,12 +485,12 @@ export class PipelineInputReader {
573
485
  #retry(value: unknown, path: string): PipelinePlanRetry | null {
574
486
  if (value === undefined) return null;
575
487
  if (typeof value === "number") {
576
- const attempts = this.#int(value, path, 1, pipelinePlanLimits.retryAttempts);
488
+ const attempts = this.#read.int(value, path, 1, pipelinePlanLimits.retryAttempts);
577
489
  return attempts === null ? null : { attempts, delaySeconds: 0 };
578
490
  }
579
- const raw = this.#object(value, path);
580
- if (!raw || !this.#keys(raw, path, ["attempts", "delay"])) return null;
581
- const attempts = this.#int(raw.attempts, `${path}.attempts`, 1, pipelinePlanLimits.retryAttempts);
491
+ const raw = this.#read.object(value, path);
492
+ if (!raw || !this.#read.keys(raw, path, ["attempts", "delay"])) return null;
493
+ const attempts = this.#read.int(raw.attempts, `${path}.attempts`, 1, pipelinePlanLimits.retryAttempts);
582
494
  const delaySeconds = this.#duration(raw.delay, `${path}.delay`, {
583
495
  min: 0,
584
496
  max: pipelinePlanLimits.retryDelaySeconds,
@@ -598,7 +510,7 @@ export class PipelineInputReader {
598
510
  if (!Number.isInteger(seconds) || seconds < min || seconds > max) {
599
511
  this.#report(
600
512
  "pipeline.duration.invalid",
601
- `${JSON.stringify(value)} is not a duration between ${min} and ${max} seconds: whole seconds, or "30s", "20m", "1h"`,
513
+ `${PipelineInputReader.#show(value)} is not a duration between ${min} and ${max} seconds: whole seconds, or "30s", "20m", "1h"`,
602
514
  path,
603
515
  );
604
516
  return null;
@@ -607,19 +519,19 @@ export class PipelineInputReader {
607
519
  }
608
520
 
609
521
  #maxParallel(value: unknown, path: string) {
610
- return value === undefined ? null : this.#int(value, path, 1, pipelinePlanLimits.stages);
522
+ return value === undefined ? null : this.#read.int(value, path, 1, pipelinePlanLimits.stages);
611
523
  }
612
524
 
613
525
  #milestone(value: unknown, path: string) {
614
- return value === undefined ? null : this.#oneOf(value, path, pipelineMilestones);
526
+ return value === undefined ? null : this.#read.oneOf(value, path, pipelineMilestones);
615
527
  }
616
528
 
617
529
  #description(value: unknown, path: string) {
618
- return value === undefined ? null : this.#text(value, path, pipelinePlanLimits.descriptionChars);
530
+ return value === undefined ? null : this.#read.string(value, path, pipelinePlanLimits.descriptionChars);
619
531
  }
620
532
 
621
533
  #id(value: unknown, path: string) {
622
- const id = this.#text(value, path, 64);
534
+ const id = this.#read.string(value, path, 64);
623
535
  if (id === null) return null;
624
536
  const problem = PipelineNames.idError(id);
625
537
  if (problem) {
@@ -657,80 +569,72 @@ export class PipelineInputReader {
657
569
  .split("\n")
658
570
  .map((text) => text.trim())
659
571
  .find(Boolean);
660
- const name = line ?? run.trim();
661
- return name.length > PipelineInputReader.#stepNameChars
662
- ? `${name.slice(0, PipelineInputReader.#stepNameChars - 1)}…`
663
- : name;
572
+ return PipelineInputReader.clip(line ?? run.trim(), PipelineInputReader.#stepNameChars);
664
573
  }
665
574
 
666
- #keys(raw: JsonObject, path: string, keys: readonly string[]) {
667
- let ok = true;
668
- for (const key of Object.keys(raw)) {
669
- if (keys.includes(key)) continue;
670
- ok = false;
671
- if (PipelineInputReader.#futureKeys.includes(key))
672
- this.#report("pipeline.schema.unsupported", `\`${key}\` is not supported in schemaVersion 1`, `${path}.${key}`);
673
- else
674
- this.#report("pipeline.schema.unknownKey", `"${key}" is not a key here: ${keys.join(", ")}`, `${path}.${key}`);
575
+ static #show(value: unknown) {
576
+ let text: string;
577
+ try {
578
+ text = JSON.stringify(value) ?? String(value);
579
+ } catch {
580
+ text = typeof value === "bigint" ? `${value}n` : Object.prototype.toString.call(value);
675
581
  }
676
- return ok;
677
- }
678
-
679
- #object(value: unknown, path: string, keys?: readonly string[]): JsonObject | null {
680
- if (typeof value !== "object" || value === null || Array.isArray(value)) {
681
- this.#report("pipeline.schema.invalid", "must be an object", path);
682
- return null;
683
- }
684
- const raw = value as JsonObject;
685
- if (keys && !this.#keys(raw, path, keys)) return null;
686
- return raw;
582
+ return PipelineInputReader.clip(text, PipelineInputReader.#shownChars);
687
583
  }
688
584
 
689
585
  #list(value: unknown, path: string): unknown[] {
690
- if (value === undefined) return [];
691
- if (!Array.isArray(value)) {
692
- this.#report("pipeline.schema.invalid", "must be a list", path);
693
- return [];
694
- }
695
- return value;
586
+ return value === undefined ? [] : (this.#read.list(value, path) ?? []);
696
587
  }
697
588
 
698
589
  #strings(value: unknown, path: string): string[] {
699
590
  return this.#list(value, path).flatMap((item, idx) => {
700
- const text = this.#text(item, `${path}[${idx}]`, 1024);
591
+ const text = this.#read.string(item, `${path}[${idx}]`);
701
592
  return text === null ? [] : [text];
702
593
  });
703
594
  }
704
595
 
705
- #text(value: unknown, path: string, max: number) {
706
- if (typeof value !== "string" || !value.trim()) {
707
- this.#report("pipeline.schema.invalid", "must be a non-empty string", path);
708
- return null;
709
- }
710
- if (value.length > max) {
711
- this.#report("pipeline.schema.invalid", `is longer than ${max} characters`, path);
712
- return null;
713
- }
714
- return value;
715
- }
716
-
717
596
  #boolean(value: unknown, path: string) {
718
- if (value === undefined) return false;
719
- if (typeof value === "boolean") return value;
720
- this.#report("pipeline.schema.invalid", "must be true or false", path);
721
- return false;
597
+ return value === undefined ? false : (this.#read.boolean(value, path) ?? false);
722
598
  }
723
599
 
724
- #int(value: unknown, path: string, min: number, max: number) {
725
- if (typeof value === "number" && Number.isInteger(value) && value >= min && value <= max) return value;
726
- this.#report("pipeline.schema.invalid", `must be a whole number between ${min} and ${max}`, path);
727
- return null;
600
+ #problem(path: string, problem: PipelineValueProblem) {
601
+ if (problem.kind !== "unknownKey")
602
+ this.#report("pipeline.schema.invalid", PipelineInputReader.#reason(problem), path);
603
+ else if (PipelineInputReader.#futureKeys.includes(problem.key))
604
+ this.#report(
605
+ "pipeline.schema.unsupported",
606
+ `\`${problem.key}\` is not supported in schemaVersion 1`,
607
+ `${path}.${problem.key}`,
608
+ );
609
+ else
610
+ this.#report(
611
+ "pipeline.schema.unknownKey",
612
+ `"${problem.key}" is not a key here: ${problem.keys.join(", ")}`,
613
+ `${path}.${problem.key}`,
614
+ );
728
615
  }
729
616
 
730
- #oneOf<T extends string>(value: unknown, path: string, options: readonly T[]): T | null {
731
- if (typeof value === "string" && (options as readonly string[]).includes(value)) return value as T;
732
- this.#report("pipeline.schema.invalid", `${JSON.stringify(value)} is not one of ${options.join(", ")}`, path);
733
- return null;
617
+ static #reason(problem: Exclude<PipelineValueProblem, { kind: "unknownKey" }>): string {
618
+ switch (problem.kind) {
619
+ case "object":
620
+ return "must be an object";
621
+ case "missingKey":
622
+ return `"${problem.key}" is missing`;
623
+ case "list":
624
+ return "must be a list";
625
+ case "string":
626
+ case "empty":
627
+ return "must be a non-empty string";
628
+ case "length":
629
+ return `is longer than ${problem.max} characters`;
630
+ case "boolean":
631
+ return "must be true or false";
632
+ case "int":
633
+ case "range":
634
+ return `must be a whole number between ${problem.min} and ${problem.max}`;
635
+ case "oneOf":
636
+ return `${PipelineInputReader.#show(problem.value)} is not one of ${problem.options.join(", ")}`;
637
+ }
734
638
  }
735
639
 
736
640
  #report(code: string, message: string, path: string) {