@treeseed/sdk 0.10.23 → 0.10.25
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/index.d.ts +12 -2
- package/dist/index.js +42 -1
- package/dist/market-client.d.ts +23 -0
- package/dist/market-client.js +30 -0
- package/dist/operations/providers/default.js +103 -10
- package/dist/operations/repository-operations.d.ts +6 -1
- package/dist/operations/repository-operations.js +44 -0
- package/dist/operations/services/bootstrap-runner.d.ts +5 -1
- package/dist/operations/services/bootstrap-runner.js +34 -5
- package/dist/operations/services/config-runtime.d.ts +25 -9
- package/dist/operations/services/config-runtime.js +60 -12
- package/dist/operations/services/deploy.js +6 -1
- package/dist/operations/services/hub-launch.js +1 -0
- package/dist/operations/services/hub-provider-launch.d.ts +11 -1
- package/dist/operations/services/hub-provider-launch.js +81 -8
- package/dist/operations/services/project-host-operations.d.ts +153 -0
- package/dist/operations/services/project-host-operations.js +365 -0
- package/dist/operations/services/project-platform.d.ts +207 -177
- package/dist/operations/services/project-platform.js +96 -29
- package/dist/operations/services/railway-deploy.d.ts +33 -1
- package/dist/operations/services/railway-deploy.js +153 -44
- package/dist/operations/services/release-candidate.js +8 -2
- package/dist/operations/services/template-host-bindings.d.ts +68 -0
- package/dist/operations/services/template-host-bindings.js +400 -0
- package/dist/operations/services/template-registry.d.ts +22 -2
- package/dist/operations/services/template-registry.js +93 -6
- package/dist/operations/services/template-secret-sync.d.ts +97 -0
- package/dist/operations/services/template-secret-sync.js +292 -0
- package/dist/platform/contracts.d.ts +1 -0
- package/dist/platform/deploy-config.js +8 -1
- package/dist/platform/deploy-runtime.js +1 -0
- package/dist/platform/environment.d.ts +3 -0
- package/dist/project-workflow.d.ts +7 -1
- package/dist/reconcile/engine.d.ts +2 -0
- package/dist/reconcile/engine.js +58 -3
- package/dist/scripts/scaffold-site.js +3 -2
- package/dist/scripts/test-scaffold.js +2 -1
- package/dist/sdk-types.d.ts +87 -0
- package/dist/sdk-types.js +29 -0
- package/dist/template-catalog.js +3 -1
- package/dist/template-launch-requirements.d.ts +118 -0
- package/dist/template-launch-requirements.js +759 -0
- package/dist/template-launch-ui.d.ts +85 -0
- package/dist/template-launch-ui.js +189 -0
- package/dist/timing.d.ts +20 -0
- package/dist/timing.js +73 -0
- package/dist/treeseed/template-catalog/catalog.fixture.json +477 -0
- package/package.json +13 -1
- package/templates/github/deploy-web.workflow.yml +4 -0
|
@@ -0,0 +1,365 @@
|
|
|
1
|
+
import {
|
|
2
|
+
executePlatformRepositoryOperation
|
|
3
|
+
} from "../repository-operations.js";
|
|
4
|
+
import {
|
|
5
|
+
ProjectLaunchSecretSyncError,
|
|
6
|
+
syncProjectLaunchHostBindingSecrets
|
|
7
|
+
} from "./template-secret-sync.js";
|
|
8
|
+
import {
|
|
9
|
+
resolveProjectLaunchHostBindings
|
|
10
|
+
} from "../../template-launch-requirements.js";
|
|
11
|
+
function requirementByKey(requirements) {
|
|
12
|
+
return new Map((requirements?.hosts ?? []).map((requirement) => [requirement.key, requirement]));
|
|
13
|
+
}
|
|
14
|
+
function bindingMode(binding) {
|
|
15
|
+
if (!binding) return null;
|
|
16
|
+
if (binding.managedHostKey || binding.host?.ownership === "treeseed_managed" || binding.provenance.selectedBy === "managed-default") return "treeseed_managed";
|
|
17
|
+
if (binding.hostId || binding.host?.id) return "team_owned";
|
|
18
|
+
return "none";
|
|
19
|
+
}
|
|
20
|
+
function inputFromResolved(binding) {
|
|
21
|
+
return {
|
|
22
|
+
requirementKey: binding.requirementKey,
|
|
23
|
+
requirementKind: binding.requirementKind,
|
|
24
|
+
type: binding.type,
|
|
25
|
+
provider: binding.provider,
|
|
26
|
+
hostId: binding.hostId ?? binding.host?.id ?? null,
|
|
27
|
+
managedHostKey: binding.managedHostKey ?? null,
|
|
28
|
+
mode: bindingMode(binding),
|
|
29
|
+
displayName: binding.displayName,
|
|
30
|
+
environmentScopes: binding.environmentScopes,
|
|
31
|
+
configValues: binding.configValues,
|
|
32
|
+
environmentValues: binding.environmentValues,
|
|
33
|
+
secretRefs: binding.secretRefs,
|
|
34
|
+
selectedBy: binding.provenance.selectedBy
|
|
35
|
+
};
|
|
36
|
+
}
|
|
37
|
+
function inventoryFromResolved(binding) {
|
|
38
|
+
const id = binding.host?.id ?? binding.hostId ?? binding.managedHostKey ?? null;
|
|
39
|
+
if (!id) return null;
|
|
40
|
+
return {
|
|
41
|
+
id,
|
|
42
|
+
type: binding.type,
|
|
43
|
+
provider: binding.provider,
|
|
44
|
+
ownership: binding.host?.ownership ?? (binding.managedHostKey ? "treeseed_managed" : null),
|
|
45
|
+
name: binding.host?.name ?? binding.displayName,
|
|
46
|
+
accountLabel: binding.host?.accountLabel ?? null,
|
|
47
|
+
organizationOrOwner: binding.host?.organizationOrOwner ?? null,
|
|
48
|
+
allowedEnvironments: binding.environmentScopes,
|
|
49
|
+
status: binding.host?.status ?? "active",
|
|
50
|
+
metadata: {
|
|
51
|
+
...binding.host?.metadata ?? {},
|
|
52
|
+
hostType: binding.type
|
|
53
|
+
}
|
|
54
|
+
};
|
|
55
|
+
}
|
|
56
|
+
function mergeInventory(currentBindings, input, type) {
|
|
57
|
+
const records = [...input ?? []];
|
|
58
|
+
const seen = new Set(records.map((record) => record.id));
|
|
59
|
+
for (const binding of Object.values(currentBindings)) {
|
|
60
|
+
if (binding.type !== type) continue;
|
|
61
|
+
const record = inventoryFromResolved(binding);
|
|
62
|
+
if (record && !seen.has(record.id)) {
|
|
63
|
+
records.push(record);
|
|
64
|
+
seen.add(record.id);
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
return records;
|
|
68
|
+
}
|
|
69
|
+
function mergeTeamHostInventory(currentBindings, input) {
|
|
70
|
+
const records = [...input ?? []];
|
|
71
|
+
const seen = new Set(records.map((record) => record.id));
|
|
72
|
+
for (const binding of Object.values(currentBindings)) {
|
|
73
|
+
if (!["web", "email", "ai"].includes(binding.type)) continue;
|
|
74
|
+
const record = inventoryFromResolved(binding);
|
|
75
|
+
if (record && !seen.has(record.id)) {
|
|
76
|
+
records.push(record);
|
|
77
|
+
seen.add(record.id);
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
return records;
|
|
81
|
+
}
|
|
82
|
+
function hostRequirementInputSet(currentHostBindings, replacementHostBindings) {
|
|
83
|
+
const inputs = {};
|
|
84
|
+
for (const [key, binding] of Object.entries(currentHostBindings)) {
|
|
85
|
+
inputs[key] = inputFromResolved(binding);
|
|
86
|
+
}
|
|
87
|
+
for (const [key, binding] of Object.entries(replacementHostBindings ?? {})) {
|
|
88
|
+
inputs[key] = binding;
|
|
89
|
+
}
|
|
90
|
+
return inputs;
|
|
91
|
+
}
|
|
92
|
+
function bindingChanged(previous, next) {
|
|
93
|
+
return JSON.stringify({
|
|
94
|
+
provider: previous?.provider ?? null,
|
|
95
|
+
hostId: previous?.hostId ?? previous?.host?.id ?? null,
|
|
96
|
+
managedHostKey: previous?.managedHostKey ?? null,
|
|
97
|
+
mode: bindingMode(previous)
|
|
98
|
+
}) !== JSON.stringify({
|
|
99
|
+
provider: next?.provider ?? null,
|
|
100
|
+
hostId: next?.hostId ?? next?.host?.id ?? null,
|
|
101
|
+
managedHostKey: next?.managedHostKey ?? null,
|
|
102
|
+
mode: bindingMode(next)
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
function requirementDiagnostics(requirement, binding) {
|
|
106
|
+
const diagnostics = [];
|
|
107
|
+
const hostId = binding?.hostId ?? binding?.host?.id ?? binding?.managedHostKey ?? null;
|
|
108
|
+
if (!binding || bindingMode(binding) === "none") {
|
|
109
|
+
if (requirement.required) {
|
|
110
|
+
diagnostics.push({
|
|
111
|
+
code: "missing_required_host",
|
|
112
|
+
status: "blocked",
|
|
113
|
+
message: `${requirement.displayName} is required and has no selected host.`,
|
|
114
|
+
requirementKey: requirement.key
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return diagnostics;
|
|
118
|
+
}
|
|
119
|
+
if (binding.type !== requirement.type) {
|
|
120
|
+
diagnostics.push({
|
|
121
|
+
code: "incompatible_host_type",
|
|
122
|
+
status: "blocked",
|
|
123
|
+
message: `${requirement.displayName} requires ${requirement.type} hosts, but ${binding.type} is selected.`,
|
|
124
|
+
requirementKey: requirement.key,
|
|
125
|
+
provider: binding.provider,
|
|
126
|
+
hostId
|
|
127
|
+
});
|
|
128
|
+
}
|
|
129
|
+
if (requirement.compatibleProviders?.length && !requirement.compatibleProviders.includes(binding.provider)) {
|
|
130
|
+
diagnostics.push({
|
|
131
|
+
code: "incompatible_provider",
|
|
132
|
+
status: "blocked",
|
|
133
|
+
message: `${requirement.displayName} requires ${requirement.compatibleProviders.join(", ")} provider support.`,
|
|
134
|
+
requirementKey: requirement.key,
|
|
135
|
+
provider: binding.provider,
|
|
136
|
+
hostId
|
|
137
|
+
});
|
|
138
|
+
}
|
|
139
|
+
const status = String(binding.host?.status ?? "").trim();
|
|
140
|
+
if (status && !["active", "ready"].includes(status)) {
|
|
141
|
+
diagnostics.push({
|
|
142
|
+
code: "host_not_ready",
|
|
143
|
+
status: requirement.required ? "blocked" : "warning",
|
|
144
|
+
message: `${requirement.displayName} host is ${status}.`,
|
|
145
|
+
requirementKey: requirement.key,
|
|
146
|
+
provider: binding.provider,
|
|
147
|
+
hostId
|
|
148
|
+
});
|
|
149
|
+
}
|
|
150
|
+
return diagnostics;
|
|
151
|
+
}
|
|
152
|
+
function worstStatus(diagnostics) {
|
|
153
|
+
if (diagnostics.some((diagnostic) => diagnostic.status === "blocked")) return "blocked";
|
|
154
|
+
if (diagnostics.some((diagnostic) => diagnostic.status === "warning")) return "warning";
|
|
155
|
+
return "ok";
|
|
156
|
+
}
|
|
157
|
+
function deriveProjectHostBindingsView(options) {
|
|
158
|
+
const bindings = options.hostBindings ?? {};
|
|
159
|
+
const configWrites = options.hostBindingPlans?.configWrites ?? [];
|
|
160
|
+
const secretItems = options.hostBindingPlans?.secretDeployment?.items ?? [];
|
|
161
|
+
const requirements = (options.launchRequirements?.hosts ?? []).map((requirement) => {
|
|
162
|
+
const binding = bindings[requirement.key];
|
|
163
|
+
const diagnostics2 = requirementDiagnostics(requirement, binding);
|
|
164
|
+
const marketHostId = binding?.hostId ?? binding?.host?.id ?? binding?.managedHostKey ?? null;
|
|
165
|
+
const scopedConfigWrites = configWrites.filter((write) => write.requirementKey === requirement.key);
|
|
166
|
+
const scopedSecretItems = secretItems.filter((item) => item.requirementKey === requirement.key);
|
|
167
|
+
return {
|
|
168
|
+
requirementKey: requirement.key,
|
|
169
|
+
displayName: requirement.displayName,
|
|
170
|
+
type: requirement.type,
|
|
171
|
+
required: requirement.required,
|
|
172
|
+
purpose: requirement.purpose,
|
|
173
|
+
compatibleProviders: requirement.compatibleProviders ?? [],
|
|
174
|
+
binding: binding ? {
|
|
175
|
+
provider: binding.provider,
|
|
176
|
+
hostId: binding.hostId ?? binding.host?.id ?? null,
|
|
177
|
+
managedHostKey: binding.managedHostKey ?? null,
|
|
178
|
+
mode: bindingMode(binding),
|
|
179
|
+
displayName: binding.displayName,
|
|
180
|
+
ownership: binding.host?.ownership ?? null,
|
|
181
|
+
status: binding.host?.status ?? null,
|
|
182
|
+
environmentScopes: binding.environmentScopes,
|
|
183
|
+
selectedBy: binding.provenance.selectedBy,
|
|
184
|
+
selectedAt: binding.provenance.selectedAt
|
|
185
|
+
} : null,
|
|
186
|
+
configWrites: scopedConfigWrites.map((write) => ({
|
|
187
|
+
target: write.target,
|
|
188
|
+
path: write.path,
|
|
189
|
+
valueFrom: write.valueFrom,
|
|
190
|
+
provider: write.provider ?? binding?.provider ?? null
|
|
191
|
+
})),
|
|
192
|
+
secretTargets: scopedSecretItems.map((item) => ({
|
|
193
|
+
env: item.env,
|
|
194
|
+
targets: item.targets,
|
|
195
|
+
scopes: item.scopes,
|
|
196
|
+
sensitivity: item.sensitivity,
|
|
197
|
+
provider: binding?.provider ?? null
|
|
198
|
+
})),
|
|
199
|
+
audit: {
|
|
200
|
+
status: worstStatus(diagnostics2),
|
|
201
|
+
diagnostics: diagnostics2,
|
|
202
|
+
marketHostId,
|
|
203
|
+
repositoryConfig: scopedConfigWrites.length > 0 ? "planned" : "not_declared"
|
|
204
|
+
}
|
|
205
|
+
};
|
|
206
|
+
});
|
|
207
|
+
const diagnostics = requirements.flatMap((requirement) => requirement.audit.diagnostics);
|
|
208
|
+
return {
|
|
209
|
+
requirements,
|
|
210
|
+
summary: {
|
|
211
|
+
status: worstStatus(diagnostics),
|
|
212
|
+
total: requirements.length,
|
|
213
|
+
blocked: diagnostics.filter((diagnostic) => diagnostic.status === "blocked").length,
|
|
214
|
+
warnings: diagnostics.filter((diagnostic) => diagnostic.status === "warning").length
|
|
215
|
+
},
|
|
216
|
+
diagnostics
|
|
217
|
+
};
|
|
218
|
+
}
|
|
219
|
+
function planProjectHostBindingOperation(options) {
|
|
220
|
+
const current = options.currentHostBindings ?? {};
|
|
221
|
+
const replacements = options.replacementHostBindings ?? {};
|
|
222
|
+
const requirementsByKey = requirementByKey(options.launchRequirements);
|
|
223
|
+
const requirementKey = options.requirementKey ?? Object.keys(replacements)[0] ?? null;
|
|
224
|
+
if (requirementKey && !requirementsByKey.has(requirementKey)) {
|
|
225
|
+
throw new Error(`Unknown launch host requirement "${requirementKey}".`);
|
|
226
|
+
}
|
|
227
|
+
const inputs = hostRequirementInputSet(current, replacements);
|
|
228
|
+
const resolved = resolveProjectLaunchHostBindings({
|
|
229
|
+
hostBindings: inputs,
|
|
230
|
+
launchRequirements: options.launchRequirements,
|
|
231
|
+
repositoryHosts: mergeInventory(current, options.repositoryHosts, "repository"),
|
|
232
|
+
teamHosts: mergeTeamHostInventory(current, options.teamHosts),
|
|
233
|
+
managedHosts: options.managedHosts,
|
|
234
|
+
defaultHosts: options.defaultHosts,
|
|
235
|
+
projectSlug: options.projectSlug,
|
|
236
|
+
projectName: options.projectName,
|
|
237
|
+
standardProjectLaunch: true,
|
|
238
|
+
selectedAt: options.selectedAt
|
|
239
|
+
});
|
|
240
|
+
const changedRequirementKeys = [.../* @__PURE__ */ new Set([
|
|
241
|
+
...Object.keys(current),
|
|
242
|
+
...Object.keys(resolved.hostBindings)
|
|
243
|
+
])].filter((key) => bindingChanged(current[key], resolved.hostBindings[key]));
|
|
244
|
+
const hostBindingPlans = {
|
|
245
|
+
configWrites: resolved.configWritePlan,
|
|
246
|
+
secretDeployment: resolved.secretDeploymentPlan
|
|
247
|
+
};
|
|
248
|
+
const audit = deriveProjectHostBindingsView({
|
|
249
|
+
launchRequirements: options.launchRequirements,
|
|
250
|
+
hostBindings: resolved.hostBindings,
|
|
251
|
+
hostBindingPlans
|
|
252
|
+
});
|
|
253
|
+
const scopedKeys = requirementKey ? [requirementKey] : changedRequirementKeys;
|
|
254
|
+
return {
|
|
255
|
+
kind: options.kind,
|
|
256
|
+
requirementKey,
|
|
257
|
+
previousHostBindings: current,
|
|
258
|
+
nextHostBindings: resolved.hostBindings,
|
|
259
|
+
compatibility: resolved.compatibility,
|
|
260
|
+
hostBindingPlans,
|
|
261
|
+
audit,
|
|
262
|
+
operationSummary: {
|
|
263
|
+
requiresRepositoryConfigWrite: resolved.configWritePlan.some((write) => scopedKeys.length === 0 || scopedKeys.includes(write.requirementKey)),
|
|
264
|
+
requiresSecretSync: (resolved.secretDeploymentPlan.items ?? []).some((item) => scopedKeys.length === 0 || scopedKeys.includes(item.requirementKey)),
|
|
265
|
+
changedRequirementKeys
|
|
266
|
+
}
|
|
267
|
+
};
|
|
268
|
+
}
|
|
269
|
+
function scopedRequirementKeys(input) {
|
|
270
|
+
if (input.requirementKey) return [input.requirementKey];
|
|
271
|
+
if (input.kind === "replace") return input.operationSummary?.changedRequirementKeys ?? [];
|
|
272
|
+
return [];
|
|
273
|
+
}
|
|
274
|
+
function scopedPlans(input) {
|
|
275
|
+
const keys = scopedRequirementKeys(input);
|
|
276
|
+
if (keys.length === 0) return input.hostBindingPlans;
|
|
277
|
+
const keySet = new Set(keys);
|
|
278
|
+
return {
|
|
279
|
+
configWrites: input.hostBindingPlans.configWrites.filter((write) => keySet.has(write.requirementKey)),
|
|
280
|
+
secretDeployment: {
|
|
281
|
+
items: (input.hostBindingPlans.secretDeployment.items ?? []).filter((item) => keySet.has(item.requirementKey))
|
|
282
|
+
}
|
|
283
|
+
};
|
|
284
|
+
}
|
|
285
|
+
function repositorySlug(result) {
|
|
286
|
+
return result.repository.owner ? `${result.repository.owner}/${result.repository.name}` : result.repository.name;
|
|
287
|
+
}
|
|
288
|
+
async function executeProjectHostBindingOperation(input, context) {
|
|
289
|
+
const plans = scopedPlans(input);
|
|
290
|
+
const requiresRepositoryConfigWrite = input.kind === "replace" && plans.configWrites.length > 0;
|
|
291
|
+
const repositoryOperation = requiresRepositoryConfigWrite ? "apply_host_binding_config" : "audit_host_binding_config";
|
|
292
|
+
const repositoryResult = await executePlatformRepositoryOperation(repositoryOperation, {
|
|
293
|
+
projectId: input.projectId ?? void 0,
|
|
294
|
+
teamId: input.teamId ?? void 0,
|
|
295
|
+
repository: input.repository,
|
|
296
|
+
hostBindings: input.hostBindings,
|
|
297
|
+
hostBindingPlans: plans,
|
|
298
|
+
launchInput: {
|
|
299
|
+
projectSlug: input.projectSlug ?? null,
|
|
300
|
+
projectName: input.projectName ?? null,
|
|
301
|
+
repoName: input.repositoryName ?? input.projectSlug ?? null
|
|
302
|
+
},
|
|
303
|
+
derived: {
|
|
304
|
+
projectSlug: input.projectSlug ?? null,
|
|
305
|
+
projectName: input.projectName ?? null,
|
|
306
|
+
repositoryName: input.repositoryName ?? input.projectSlug ?? null
|
|
307
|
+
},
|
|
308
|
+
commitMessage: input.commitMessage ?? void 0,
|
|
309
|
+
approvalRequired: input.approvalRequired,
|
|
310
|
+
approvalId: input.approvalId ?? void 0
|
|
311
|
+
}, {
|
|
312
|
+
workspaceRoot: context.workspaceRoot,
|
|
313
|
+
environment: context.environment
|
|
314
|
+
});
|
|
315
|
+
let secretSync = null;
|
|
316
|
+
const requiresSecretSync = ["replace", "resync", "rotate"].includes(input.kind) && (plans.secretDeployment.items ?? []).length > 0;
|
|
317
|
+
if (requiresSecretSync) {
|
|
318
|
+
try {
|
|
319
|
+
secretSync = await syncProjectLaunchHostBindingSecrets({
|
|
320
|
+
projectRoot: repositoryResult.repositoryPath,
|
|
321
|
+
repository: repositorySlug(repositoryResult),
|
|
322
|
+
hostBindings: input.hostBindings,
|
|
323
|
+
secretDeploymentPlan: plans.secretDeployment,
|
|
324
|
+
valuesOverlay: context.valuesOverlay,
|
|
325
|
+
valuesByScope: context.valuesByScope,
|
|
326
|
+
processEnv: context.processEnv,
|
|
327
|
+
dryRun: input.dryRun,
|
|
328
|
+
onProgress: context.onProgress
|
|
329
|
+
});
|
|
330
|
+
} catch (error) {
|
|
331
|
+
if (error instanceof ProjectLaunchSecretSyncError) {
|
|
332
|
+
secretSync = error.result;
|
|
333
|
+
} else {
|
|
334
|
+
throw error;
|
|
335
|
+
}
|
|
336
|
+
}
|
|
337
|
+
}
|
|
338
|
+
return {
|
|
339
|
+
ok: secretSync ? secretSync.ok : true,
|
|
340
|
+
kind: input.kind,
|
|
341
|
+
requirementKey: input.requirementKey ?? null,
|
|
342
|
+
hostBindings: input.hostBindings,
|
|
343
|
+
previousHostBindings: input.previousHostBindings ?? {},
|
|
344
|
+
hostBindingPlans: plans,
|
|
345
|
+
repository: {
|
|
346
|
+
operation: repositoryOperation,
|
|
347
|
+
branch: repositoryResult.operationBranch ?? repositoryResult.branch,
|
|
348
|
+
commitSha: repositoryResult.commitSha,
|
|
349
|
+
changedPaths: repositoryResult.changedPaths,
|
|
350
|
+
audit: repositoryResult.output.hostBindingAudit ?? null,
|
|
351
|
+
config: repositoryResult.output.hostBindingConfig ?? null
|
|
352
|
+
},
|
|
353
|
+
secretSync,
|
|
354
|
+
summary: {
|
|
355
|
+
requiresRepositoryConfigWrite,
|
|
356
|
+
requiresSecretSync,
|
|
357
|
+
changedRequirementKeys: input.operationSummary?.changedRequirementKeys ?? []
|
|
358
|
+
}
|
|
359
|
+
};
|
|
360
|
+
}
|
|
361
|
+
export {
|
|
362
|
+
deriveProjectHostBindingsView,
|
|
363
|
+
executeProjectHostBindingOperation,
|
|
364
|
+
planProjectHostBindingOperation
|
|
365
|
+
};
|