@tinycloud-ai/tinycloud-cli 0.1.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.
- package/README.md +32 -0
- package/dist/main.js +1943 -0
- package/dist/main.js.map +7 -0
- package/package.json +46 -0
- package/schema/tiny.v1alpha1.json +234 -0
package/dist/main.js
ADDED
|
@@ -0,0 +1,1943 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
// ../cli/src/main.ts
|
|
4
|
+
import { existsSync as existsSync3, readFileSync as readFileSync4, writeFileSync as writeFileSync3 } from "node:fs";
|
|
5
|
+
import { basename, join as join5, resolve } from "node:path";
|
|
6
|
+
import { createInterface } from "node:readline/promises";
|
|
7
|
+
|
|
8
|
+
// ../../packages/domain/src/ids.ts
|
|
9
|
+
var lastRandom = new Uint8Array(10);
|
|
10
|
+
|
|
11
|
+
// ../../packages/domain/src/errors.ts
|
|
12
|
+
var ERROR_CODES = {
|
|
13
|
+
// Request/auth
|
|
14
|
+
UNAUTHENTICATED: 401,
|
|
15
|
+
AUTH_PROVIDER_REQUIRED: 501,
|
|
16
|
+
FORBIDDEN: 403,
|
|
17
|
+
NOT_FOUND: 404,
|
|
18
|
+
CONFLICT: 409,
|
|
19
|
+
PRECONDITION_FAILED: 412,
|
|
20
|
+
VALIDATION_FAILED: 422,
|
|
21
|
+
PAYLOAD_TOO_LARGE: 413,
|
|
22
|
+
RATE_LIMITED: 429,
|
|
23
|
+
INTERNAL: 500,
|
|
24
|
+
// Manifest
|
|
25
|
+
MANIFEST_PARSE_FAILED: 422,
|
|
26
|
+
MANIFEST_SCHEMA_INVALID: 422,
|
|
27
|
+
MANIFEST_POLICY_VIOLATION: 422,
|
|
28
|
+
MANIFEST_UNSUPPORTED_API_VERSION: 422,
|
|
29
|
+
// Planning / provider
|
|
30
|
+
TARGET_INCOMPATIBLE: 422,
|
|
31
|
+
APPROVAL_REQUIRED: 409,
|
|
32
|
+
PLAN_EXPIRED: 409,
|
|
33
|
+
PLAN_STALE: 409,
|
|
34
|
+
PROVIDER_UNAVAILABLE: 503,
|
|
35
|
+
BUILD_FAILED: 422,
|
|
36
|
+
HEALTHCHECK_FAILED: 422,
|
|
37
|
+
// State
|
|
38
|
+
MIGRATION_CHECKSUM_CHANGED: 422,
|
|
39
|
+
MIGRATION_FAILED: 422,
|
|
40
|
+
MIGRATION_LOCKED: 409,
|
|
41
|
+
RESOURCE_LIMIT_EXCEEDED: 422,
|
|
42
|
+
// Lifecycle
|
|
43
|
+
APP_ARCHIVED: 409,
|
|
44
|
+
APP_SUSPENDED: 409,
|
|
45
|
+
DELETE_CONFIRMATION_REQUIRED: 412,
|
|
46
|
+
// Capabilities
|
|
47
|
+
CAPABILITY_NOT_GRANTED: 403,
|
|
48
|
+
CAPABILITY_CONSTRAINT_VIOLATED: 403,
|
|
49
|
+
CAPABILITY_INPUT_INVALID: 422,
|
|
50
|
+
EGRESS_DENIED: 403,
|
|
51
|
+
// CLI/agent
|
|
52
|
+
INTERACTION_REQUIRED: 412
|
|
53
|
+
};
|
|
54
|
+
var TinyError = class extends Error {
|
|
55
|
+
code;
|
|
56
|
+
remediation;
|
|
57
|
+
retryable;
|
|
58
|
+
details;
|
|
59
|
+
requestId;
|
|
60
|
+
constructor(code, message, options = {}) {
|
|
61
|
+
super(message, options.cause === void 0 ? void 0 : { cause: options.cause });
|
|
62
|
+
this.name = "TinyError";
|
|
63
|
+
this.code = code;
|
|
64
|
+
this.remediation = options.remediation ?? DEFAULT_REMEDIATION[code] ?? "No automatic remediation is available.";
|
|
65
|
+
this.retryable = options.retryable ?? RETRYABLE.has(code);
|
|
66
|
+
this.details = options.details ?? {};
|
|
67
|
+
if (options.requestId) this.requestId = options.requestId;
|
|
68
|
+
}
|
|
69
|
+
get httpStatus() {
|
|
70
|
+
return ERROR_CODES[this.code];
|
|
71
|
+
}
|
|
72
|
+
toJSON() {
|
|
73
|
+
const body = {
|
|
74
|
+
code: this.code,
|
|
75
|
+
message: this.message,
|
|
76
|
+
remediation: this.remediation,
|
|
77
|
+
retryable: this.retryable
|
|
78
|
+
};
|
|
79
|
+
if (this.requestId) body.requestId = this.requestId;
|
|
80
|
+
if (Object.keys(this.details).length > 0) body.details = this.details;
|
|
81
|
+
return { error: body };
|
|
82
|
+
}
|
|
83
|
+
};
|
|
84
|
+
var RETRYABLE = /* @__PURE__ */ new Set([
|
|
85
|
+
"RATE_LIMITED",
|
|
86
|
+
"INTERNAL",
|
|
87
|
+
"PROVIDER_UNAVAILABLE",
|
|
88
|
+
"MIGRATION_LOCKED"
|
|
89
|
+
]);
|
|
90
|
+
var DEFAULT_REMEDIATION = {
|
|
91
|
+
UNAUTHENTICATED: "Run `tiny login` and retry.",
|
|
92
|
+
AUTH_PROVIDER_REQUIRED: "Configure a trusted OIDC provider for this gateway.",
|
|
93
|
+
FORBIDDEN: "Ask an organization admin for the required role.",
|
|
94
|
+
APPROVAL_REQUIRED: "Ask an organization admin to approve the plan, then deploy again with the approved plan ID.",
|
|
95
|
+
PLAN_STALE: "Run `tiny plan` again; the manifest or policy changed since this plan was produced.",
|
|
96
|
+
MIGRATION_CHECKSUM_CHANGED: "Restore the original migration file and add a new migration instead.",
|
|
97
|
+
INTERACTION_REQUIRED: "Re-run interactively, or pass the flag named in details.missingInput.",
|
|
98
|
+
MANIFEST_SCHEMA_INVALID: "Fix the fields listed in details.issues and re-run `tiny validate`.",
|
|
99
|
+
TARGET_INCOMPATIBLE: "Choose a runtime target that supports the manifest, or lower the requested resources.",
|
|
100
|
+
DELETE_CONFIRMATION_REQUIRED: "Re-run with the exact app slug as confirmation."
|
|
101
|
+
};
|
|
102
|
+
function isTinyError(value) {
|
|
103
|
+
return value instanceof TinyError;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
// ../../packages/domain/src/duration.ts
|
|
107
|
+
var UNIT_MS = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 };
|
|
108
|
+
var PATTERN = /^([1-9][0-9]*)(s|m|h|d)$/;
|
|
109
|
+
function parseDuration(value) {
|
|
110
|
+
const match = PATTERN.exec(value);
|
|
111
|
+
if (!match) {
|
|
112
|
+
throw new TinyError("VALIDATION_FAILED", `Invalid duration "${value}".`, {
|
|
113
|
+
remediation: "Use a positive integer followed by s, m, h, or d (for example 60m or 90d).",
|
|
114
|
+
details: { value }
|
|
115
|
+
});
|
|
116
|
+
}
|
|
117
|
+
return Number(match[1]) * UNIT_MS[match[2]];
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
// ../../packages/domain/src/entities.ts
|
|
121
|
+
var DEFAULT_ORGANIZATION_POLICY = {
|
|
122
|
+
allowPublicApps: false,
|
|
123
|
+
allowRawSecrets: true,
|
|
124
|
+
requireApprovalForCapabilities: true,
|
|
125
|
+
requireApprovalForRawSecrets: true,
|
|
126
|
+
maxAppsPerOrg: 500,
|
|
127
|
+
maxPreviewTtlHours: 168,
|
|
128
|
+
maxMemoryMiB: 2048,
|
|
129
|
+
maxMonthlyRequests: 1e5,
|
|
130
|
+
maxMonthlyRequestsPerApp: 0,
|
|
131
|
+
anonymousRequestsPerMinute: 600,
|
|
132
|
+
maxMonthlyCapabilityCalls: 1e4,
|
|
133
|
+
maxMonthlyEstimatedCostCents: 5e3,
|
|
134
|
+
reservedSlugs: ["api", "app", "apps", "admin", "dashboard", "gateway", "tiny", "www"]
|
|
135
|
+
};
|
|
136
|
+
|
|
137
|
+
// ../../packages/manifest-schema/src/index.ts
|
|
138
|
+
import { createHash } from "node:crypto";
|
|
139
|
+
import { readFileSync } from "node:fs";
|
|
140
|
+
import { dirname, join } from "node:path";
|
|
141
|
+
import { fileURLToPath } from "node:url";
|
|
142
|
+
|
|
143
|
+
// ../../packages/manifest-schema/src/jsonschema.ts
|
|
144
|
+
var SUPPORTED = /* @__PURE__ */ new Set([
|
|
145
|
+
"$schema",
|
|
146
|
+
"$id",
|
|
147
|
+
"$ref",
|
|
148
|
+
"$defs",
|
|
149
|
+
"title",
|
|
150
|
+
"description",
|
|
151
|
+
"examples",
|
|
152
|
+
"default",
|
|
153
|
+
"type",
|
|
154
|
+
"const",
|
|
155
|
+
"enum",
|
|
156
|
+
"required",
|
|
157
|
+
"properties",
|
|
158
|
+
"additionalProperties",
|
|
159
|
+
"items",
|
|
160
|
+
"minItems",
|
|
161
|
+
"maxItems",
|
|
162
|
+
"uniqueItems",
|
|
163
|
+
"minimum",
|
|
164
|
+
"maximum",
|
|
165
|
+
"exclusiveMinimum",
|
|
166
|
+
"exclusiveMaximum",
|
|
167
|
+
"multipleOf",
|
|
168
|
+
"minLength",
|
|
169
|
+
"maxLength",
|
|
170
|
+
"pattern",
|
|
171
|
+
"format",
|
|
172
|
+
"minProperties",
|
|
173
|
+
"maxProperties",
|
|
174
|
+
"propertyNames",
|
|
175
|
+
"oneOf",
|
|
176
|
+
"anyOf",
|
|
177
|
+
"allOf",
|
|
178
|
+
"not"
|
|
179
|
+
]);
|
|
180
|
+
var FORMATS = {
|
|
181
|
+
email: /^[^\s@]+@[^\s@.]+(\.[^\s@.]+)+$/,
|
|
182
|
+
hostname: /^(?=.{1,253}$)([a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)(\.[a-z0-9]([a-z0-9-]{0,61}[a-z0-9])?)*$/i,
|
|
183
|
+
date: /^\d{4}-\d{2}-\d{2}$/,
|
|
184
|
+
"date-time": /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}(\.\d+)?(Z|[+-]\d{2}:\d{2})$/,
|
|
185
|
+
uri: /^[a-z][a-z0-9+.-]*:\S+$/i
|
|
186
|
+
};
|
|
187
|
+
function typeOf(value) {
|
|
188
|
+
if (value === null) return "null";
|
|
189
|
+
if (Array.isArray(value)) return "array";
|
|
190
|
+
if (Number.isInteger(value)) return "integer";
|
|
191
|
+
return typeof value;
|
|
192
|
+
}
|
|
193
|
+
function matchesType(value, type) {
|
|
194
|
+
const actual = typeOf(value);
|
|
195
|
+
if (type === "number") return actual === "number" || actual === "integer";
|
|
196
|
+
if (type === "integer") return actual === "integer";
|
|
197
|
+
return actual === type;
|
|
198
|
+
}
|
|
199
|
+
function deepEqual(a, b) {
|
|
200
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
201
|
+
}
|
|
202
|
+
function escapePointer(token) {
|
|
203
|
+
return token.replace(/~/g, "~0").replace(/\//g, "~1");
|
|
204
|
+
}
|
|
205
|
+
var SchemaValidator = class {
|
|
206
|
+
root;
|
|
207
|
+
constructor(root) {
|
|
208
|
+
this.root = root;
|
|
209
|
+
this.assertSupported(root, "#");
|
|
210
|
+
}
|
|
211
|
+
assertSupported(schema, at) {
|
|
212
|
+
if (typeof schema !== "object" || schema === null || Array.isArray(schema)) return;
|
|
213
|
+
for (const [key, value] of Object.entries(schema)) {
|
|
214
|
+
if (!SUPPORTED.has(key)) {
|
|
215
|
+
throw new Error(`Unsupported JSON Schema keyword "${key}" at ${at}.`);
|
|
216
|
+
}
|
|
217
|
+
if (key === "properties" || key === "$defs") {
|
|
218
|
+
for (const [name, sub] of Object.entries(value)) this.assertSupported(sub, `${at}/${key}/${name}`);
|
|
219
|
+
} else if (key === "oneOf" || key === "anyOf" || key === "allOf") {
|
|
220
|
+
value.forEach((sub, i) => this.assertSupported(sub, `${at}/${key}/${i}`));
|
|
221
|
+
} else if (key === "items" || key === "not" || key === "propertyNames" || key === "additionalProperties") {
|
|
222
|
+
this.assertSupported(value, `${at}/${key}`);
|
|
223
|
+
}
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
resolve(ref) {
|
|
227
|
+
if (!ref.startsWith("#/")) throw new Error(`Only local $ref is supported, got "${ref}".`);
|
|
228
|
+
let node = this.root;
|
|
229
|
+
for (const token of ref.slice(2).split("/")) {
|
|
230
|
+
node = node?.[token.replace(/~1/g, "/").replace(/~0/g, "~")];
|
|
231
|
+
if (node === void 0) throw new Error(`Unresolved $ref "${ref}".`);
|
|
232
|
+
}
|
|
233
|
+
return node;
|
|
234
|
+
}
|
|
235
|
+
validate(instance, schema = this.root, path = "") {
|
|
236
|
+
const issues = [];
|
|
237
|
+
this.check(instance, schema, path, issues);
|
|
238
|
+
return issues;
|
|
239
|
+
}
|
|
240
|
+
check(value, schema, path, issues) {
|
|
241
|
+
if (typeof schema.$ref === "string") {
|
|
242
|
+
this.check(value, this.resolve(schema.$ref), path, issues);
|
|
243
|
+
return;
|
|
244
|
+
}
|
|
245
|
+
if (schema.type !== void 0) {
|
|
246
|
+
const types = Array.isArray(schema.type) ? schema.type : [schema.type];
|
|
247
|
+
if (!types.some((t) => matchesType(value, t))) {
|
|
248
|
+
issues.push({ path, keyword: "type", message: `expected ${types.join(" or ")} but got ${typeOf(value)}` });
|
|
249
|
+
return;
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
if (schema.const !== void 0 && !deepEqual(value, schema.const)) {
|
|
253
|
+
issues.push({ path, keyword: "const", message: `must be ${JSON.stringify(schema.const)}` });
|
|
254
|
+
}
|
|
255
|
+
if (Array.isArray(schema.enum) && !schema.enum.some((option) => deepEqual(option, value))) {
|
|
256
|
+
issues.push({ path, keyword: "enum", message: `must be one of ${schema.enum.map((o) => JSON.stringify(o)).join(", ")}` });
|
|
257
|
+
}
|
|
258
|
+
if (typeof value === "string") this.checkString(value, schema, path, issues);
|
|
259
|
+
if (typeof value === "number") this.checkNumber(value, schema, path, issues);
|
|
260
|
+
if (Array.isArray(value)) this.checkArray(value, schema, path, issues);
|
|
261
|
+
else if (typeof value === "object" && value !== null) this.checkObject(value, schema, path, issues);
|
|
262
|
+
this.checkCombinators(value, schema, path, issues);
|
|
263
|
+
}
|
|
264
|
+
checkString(value, schema, path, issues) {
|
|
265
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
266
|
+
issues.push({ path, keyword: "minLength", message: `must be at least ${schema.minLength} characters` });
|
|
267
|
+
}
|
|
268
|
+
if (typeof schema.maxLength === "number" && value.length > schema.maxLength) {
|
|
269
|
+
issues.push({ path, keyword: "maxLength", message: `must be at most ${schema.maxLength} characters` });
|
|
270
|
+
}
|
|
271
|
+
if (typeof schema.pattern === "string" && !new RegExp(schema.pattern, "u").test(value)) {
|
|
272
|
+
issues.push({ path, keyword: "pattern", message: `must match ${schema.pattern}` });
|
|
273
|
+
}
|
|
274
|
+
if (typeof schema.format === "string") {
|
|
275
|
+
const rule = FORMATS[schema.format];
|
|
276
|
+
if (rule && !rule.test(value)) {
|
|
277
|
+
issues.push({ path, keyword: "format", message: `must be a valid ${schema.format}` });
|
|
278
|
+
}
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
checkNumber(value, schema, path, issues) {
|
|
282
|
+
if (typeof schema.minimum === "number" && value < schema.minimum) {
|
|
283
|
+
issues.push({ path, keyword: "minimum", message: `must be >= ${schema.minimum}` });
|
|
284
|
+
}
|
|
285
|
+
if (typeof schema.maximum === "number" && value > schema.maximum) {
|
|
286
|
+
issues.push({ path, keyword: "maximum", message: `must be <= ${schema.maximum}` });
|
|
287
|
+
}
|
|
288
|
+
if (typeof schema.exclusiveMinimum === "number" && value <= schema.exclusiveMinimum) {
|
|
289
|
+
issues.push({ path, keyword: "exclusiveMinimum", message: `must be > ${schema.exclusiveMinimum}` });
|
|
290
|
+
}
|
|
291
|
+
if (typeof schema.exclusiveMaximum === "number" && value >= schema.exclusiveMaximum) {
|
|
292
|
+
issues.push({ path, keyword: "exclusiveMaximum", message: `must be < ${schema.exclusiveMaximum}` });
|
|
293
|
+
}
|
|
294
|
+
if (typeof schema.multipleOf === "number" && value % schema.multipleOf !== 0) {
|
|
295
|
+
issues.push({ path, keyword: "multipleOf", message: `must be a multiple of ${schema.multipleOf}` });
|
|
296
|
+
}
|
|
297
|
+
}
|
|
298
|
+
checkArray(value, schema, path, issues) {
|
|
299
|
+
if (typeof schema.minItems === "number" && value.length < schema.minItems) {
|
|
300
|
+
issues.push({ path, keyword: "minItems", message: `must contain at least ${schema.minItems} items` });
|
|
301
|
+
}
|
|
302
|
+
if (typeof schema.maxItems === "number" && value.length > schema.maxItems) {
|
|
303
|
+
issues.push({ path, keyword: "maxItems", message: `must contain at most ${schema.maxItems} items` });
|
|
304
|
+
}
|
|
305
|
+
if (schema.uniqueItems === true) {
|
|
306
|
+
const seen = new Set(value.map((item) => JSON.stringify(item)));
|
|
307
|
+
if (seen.size !== value.length) issues.push({ path, keyword: "uniqueItems", message: "must not contain duplicates" });
|
|
308
|
+
}
|
|
309
|
+
if (schema.items) {
|
|
310
|
+
value.forEach((item, i) => this.check(item, schema.items, `${path}/${i}`, issues));
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
checkObject(value, schema, path, issues) {
|
|
314
|
+
const keys = Object.keys(value);
|
|
315
|
+
if (Array.isArray(schema.required)) {
|
|
316
|
+
for (const key of schema.required) {
|
|
317
|
+
if (!(key in value)) issues.push({ path: `${path}/${escapePointer(key)}`, keyword: "required", message: "is required" });
|
|
318
|
+
}
|
|
319
|
+
}
|
|
320
|
+
if (typeof schema.maxProperties === "number" && keys.length > schema.maxProperties) {
|
|
321
|
+
issues.push({ path, keyword: "maxProperties", message: `must have at most ${schema.maxProperties} properties` });
|
|
322
|
+
}
|
|
323
|
+
if (typeof schema.minProperties === "number" && keys.length < schema.minProperties) {
|
|
324
|
+
issues.push({ path, keyword: "minProperties", message: `must have at least ${schema.minProperties} properties` });
|
|
325
|
+
}
|
|
326
|
+
const properties = schema.properties ?? {};
|
|
327
|
+
for (const key of keys) {
|
|
328
|
+
const childPath = `${path}/${escapePointer(key)}`;
|
|
329
|
+
if (schema.propertyNames) this.check(key, schema.propertyNames, childPath, issues);
|
|
330
|
+
const child = properties[key];
|
|
331
|
+
if (child) {
|
|
332
|
+
this.check(value[key], child, childPath, issues);
|
|
333
|
+
} else if (schema.additionalProperties === false) {
|
|
334
|
+
issues.push({ path: childPath, keyword: "additionalProperties", message: "is not a recognized field" });
|
|
335
|
+
} else if (typeof schema.additionalProperties === "object" && schema.additionalProperties !== null) {
|
|
336
|
+
this.check(value[key], schema.additionalProperties, childPath, issues);
|
|
337
|
+
}
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
checkCombinators(value, schema, path, issues) {
|
|
341
|
+
if (Array.isArray(schema.allOf)) {
|
|
342
|
+
for (const sub of schema.allOf) this.check(value, sub, path, issues);
|
|
343
|
+
}
|
|
344
|
+
if (Array.isArray(schema.anyOf)) {
|
|
345
|
+
const branches = schema.anyOf.map((sub) => this.validate(value, sub, path));
|
|
346
|
+
if (branches.every((b) => b.length > 0)) {
|
|
347
|
+
issues.push({ path, keyword: "anyOf", message: `did not match any allowed shape: ${branches.flat().map((i) => i.message).join("; ")}` });
|
|
348
|
+
}
|
|
349
|
+
}
|
|
350
|
+
if (Array.isArray(schema.oneOf)) {
|
|
351
|
+
const matches2 = schema.oneOf.filter((sub) => this.validate(value, sub, path).length === 0);
|
|
352
|
+
if (matches2.length !== 1) {
|
|
353
|
+
issues.push({ path, keyword: "oneOf", message: `must match exactly one allowed shape (matched ${matches2.length})` });
|
|
354
|
+
}
|
|
355
|
+
}
|
|
356
|
+
if (schema.not && this.validate(value, schema.not, path).length === 0) {
|
|
357
|
+
issues.push({ path, keyword: "not", message: "matched a forbidden shape" });
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
};
|
|
361
|
+
|
|
362
|
+
// ../../packages/manifest-schema/src/types.ts
|
|
363
|
+
var MANIFEST_API_VERSION = "tinycloud.dev/v1alpha1";
|
|
364
|
+
|
|
365
|
+
// ../../packages/manifest-schema/src/normalize.ts
|
|
366
|
+
var MANIFEST_DEFAULTS = {
|
|
367
|
+
// The guest runs a full Node binary from a virtio disk. Below roughly
|
|
368
|
+
// 256Mi its text pages no longer fit in the guest page cache, and the
|
|
369
|
+
// guest kernel spends every scheduling slice re-reading them instead of
|
|
370
|
+
// running the app; 512Mi leaves headroom above that cliff. Firecracker
|
|
371
|
+
// faults guest memory in lazily, so the unused part of the ceiling is free.
|
|
372
|
+
memory: "512Mi",
|
|
373
|
+
cpuMillis: 50,
|
|
374
|
+
requestTimeout: "15s",
|
|
375
|
+
concurrency: 20,
|
|
376
|
+
packageManager: "npm",
|
|
377
|
+
buildOutput: "dist",
|
|
378
|
+
databaseName: "app",
|
|
379
|
+
migrationsDir: "migrations",
|
|
380
|
+
retention: "30d",
|
|
381
|
+
storageMaxSize: "1Gi",
|
|
382
|
+
previewTtl: "72h",
|
|
383
|
+
logRetention: "7d",
|
|
384
|
+
sampleRate: 0.05,
|
|
385
|
+
storageClass: "object"
|
|
386
|
+
};
|
|
387
|
+
function parseSize(value) {
|
|
388
|
+
const match = /^([1-9][0-9]*)(Mi|Gi)$/.exec(value);
|
|
389
|
+
if (!match) return 0;
|
|
390
|
+
return Number(match[1]) * (match[2] === "Gi" ? 1024 ** 3 : 1024 ** 2);
|
|
391
|
+
}
|
|
392
|
+
function memoryMiB(value) {
|
|
393
|
+
return Math.round(parseSize(value) / 1024 ** 2);
|
|
394
|
+
}
|
|
395
|
+
function inferLanguage(manifest) {
|
|
396
|
+
if (manifest.runtime.language) return manifest.runtime.language;
|
|
397
|
+
return manifest.runtime.entrypoint.endsWith(".py") ? "python" : "typescript";
|
|
398
|
+
}
|
|
399
|
+
function normalizeSubjects(manifest) {
|
|
400
|
+
const subjects = (manifest.access.subjects ?? []).map((subject) => {
|
|
401
|
+
const type = subject.user ? "user" : subject.group ? "group" : "serviceAccount";
|
|
402
|
+
const value = subject.user ?? subject.group ?? subject.serviceAccount;
|
|
403
|
+
return { type, value: type === "user" ? value.toLowerCase() : value, role: subject.role ?? "user" };
|
|
404
|
+
});
|
|
405
|
+
const owner = manifest.metadata.owner.toLowerCase();
|
|
406
|
+
if (!subjects.some((s) => s.type === "user" && s.value === owner)) {
|
|
407
|
+
subjects.unshift({ type: "user", value: owner, role: "admin" });
|
|
408
|
+
}
|
|
409
|
+
return subjects;
|
|
410
|
+
}
|
|
411
|
+
function normalizeManifest(manifest) {
|
|
412
|
+
const runtimeResources = manifest.runtime.resources ?? {};
|
|
413
|
+
const database = manifest.resources?.database ?? null;
|
|
414
|
+
const egress = manifest.network?.egress ?? {};
|
|
415
|
+
return {
|
|
416
|
+
apiVersion: MANIFEST_API_VERSION,
|
|
417
|
+
kind: "App",
|
|
418
|
+
metadata: {
|
|
419
|
+
name: manifest.metadata.name,
|
|
420
|
+
displayName: manifest.metadata.displayName ?? manifest.metadata.name,
|
|
421
|
+
description: manifest.metadata.description ?? null,
|
|
422
|
+
owner: manifest.metadata.owner.toLowerCase(),
|
|
423
|
+
labels: manifest.metadata.labels ?? {}
|
|
424
|
+
},
|
|
425
|
+
runtime: {
|
|
426
|
+
type: manifest.runtime.type,
|
|
427
|
+
language: inferLanguage(manifest),
|
|
428
|
+
entrypoint: manifest.runtime.entrypoint,
|
|
429
|
+
compatibilityDate: manifest.runtime.compatibilityDate ?? null,
|
|
430
|
+
resources: {
|
|
431
|
+
memoryMiB: memoryMiB(runtimeResources.memory ?? MANIFEST_DEFAULTS.memory),
|
|
432
|
+
cpuMillis: runtimeResources.cpuMillis ?? MANIFEST_DEFAULTS.cpuMillis,
|
|
433
|
+
requestTimeoutMs: parseDuration(runtimeResources.requestTimeout ?? MANIFEST_DEFAULTS.requestTimeout),
|
|
434
|
+
concurrency: runtimeResources.concurrency ?? MANIFEST_DEFAULTS.concurrency
|
|
435
|
+
}
|
|
436
|
+
},
|
|
437
|
+
build: {
|
|
438
|
+
command: manifest.build?.command ?? null,
|
|
439
|
+
output: manifest.build?.output ?? null,
|
|
440
|
+
packageManager: manifest.build?.packageManager ?? MANIFEST_DEFAULTS.packageManager,
|
|
441
|
+
// Production deploys require a lockfile unless explicitly opted out.
|
|
442
|
+
lockfileRequired: manifest.build?.lockfileRequired ?? true
|
|
443
|
+
},
|
|
444
|
+
access: {
|
|
445
|
+
visibility: manifest.access.visibility,
|
|
446
|
+
requireLogin: manifest.access.requireLogin ?? true,
|
|
447
|
+
subjects: normalizeSubjects(manifest)
|
|
448
|
+
},
|
|
449
|
+
resources: {
|
|
450
|
+
database: database ? {
|
|
451
|
+
engine: database.engine,
|
|
452
|
+
name: database.name ?? MANIFEST_DEFAULTS.databaseName,
|
|
453
|
+
migrations: database.migrations ?? MANIFEST_DEFAULTS.migrationsDir,
|
|
454
|
+
retentionMs: parseDuration(database.retention ?? MANIFEST_DEFAULTS.retention)
|
|
455
|
+
} : null,
|
|
456
|
+
storage: (manifest.resources?.storage ?? []).map((store) => ({
|
|
457
|
+
name: store.name,
|
|
458
|
+
class: store.class ?? MANIFEST_DEFAULTS.storageClass,
|
|
459
|
+
maxSizeBytes: parseSize(store.maxSize ?? MANIFEST_DEFAULTS.storageMaxSize),
|
|
460
|
+
retentionMs: parseDuration(store.retention ?? MANIFEST_DEFAULTS.retention)
|
|
461
|
+
}))
|
|
462
|
+
},
|
|
463
|
+
capabilities: (manifest.capabilities ?? []).map((capability) => ({
|
|
464
|
+
name: capability.name,
|
|
465
|
+
connection: capability.connection,
|
|
466
|
+
// Manifests may use concise names (`payments.read`) while the catalog
|
|
467
|
+
// and runtime use globally-qualified IDs (`stripe.payments.read`).
|
|
468
|
+
allow: capability.allow.map((operation) => operation.startsWith(`${capability.name}.`) ? operation : `${capability.name}.${operation}`).sort(),
|
|
469
|
+
constraints: capability.constraints ?? {}
|
|
470
|
+
})),
|
|
471
|
+
network: {
|
|
472
|
+
egress: {
|
|
473
|
+
// Default deny: an app gets no outbound network unless it asks (§10, §27).
|
|
474
|
+
mode: egress.mode ?? "deny",
|
|
475
|
+
allow: (egress.allow ?? []).map((rule) => ({
|
|
476
|
+
host: rule.host.toLowerCase(),
|
|
477
|
+
ports: rule.ports ?? [443]
|
|
478
|
+
}))
|
|
479
|
+
}
|
|
480
|
+
},
|
|
481
|
+
secrets: (manifest.secrets ?? []).map((secret) => ({
|
|
482
|
+
name: secret.name,
|
|
483
|
+
from: secret.from,
|
|
484
|
+
version: secret.version ?? "latest"
|
|
485
|
+
})),
|
|
486
|
+
lifecycle: {
|
|
487
|
+
sleepAfterMs: manifest.lifecycle.sleepAfter ? parseDuration(manifest.lifecycle.sleepAfter) : null,
|
|
488
|
+
archiveAfterUnusedMs: manifest.lifecycle.archiveAfterUnused ? parseDuration(manifest.lifecycle.archiveAfterUnused) : null,
|
|
489
|
+
deleteAfterArchivedMs: manifest.lifecycle.deleteAfterArchived ? parseDuration(manifest.lifecycle.deleteAfterArchived) : null,
|
|
490
|
+
previewTtlMs: parseDuration(manifest.lifecycle.previewTtl ?? MANIFEST_DEFAULTS.previewTtl)
|
|
491
|
+
},
|
|
492
|
+
observability: {
|
|
493
|
+
logLevel: manifest.observability?.logLevel ?? "info",
|
|
494
|
+
retentionMs: parseDuration(manifest.observability?.retention ?? MANIFEST_DEFAULTS.logRetention),
|
|
495
|
+
traces: { sampleRate: manifest.observability?.traces?.sampleRate ?? MANIFEST_DEFAULTS.sampleRate },
|
|
496
|
+
alerts: manifest.observability?.alerts ?? []
|
|
497
|
+
}
|
|
498
|
+
};
|
|
499
|
+
}
|
|
500
|
+
function canonicalize(value) {
|
|
501
|
+
if (value === null || typeof value !== "object") return JSON.stringify(value) ?? "null";
|
|
502
|
+
if (Array.isArray(value)) return `[${value.map(canonicalize).join(",")}]`;
|
|
503
|
+
const entries = Object.entries(value).filter(([, v]) => v !== void 0).sort(([a], [b]) => a < b ? -1 : a > b ? 1 : 0);
|
|
504
|
+
return `{${entries.map(([k, v]) => `${JSON.stringify(k)}:${canonicalize(v)}`).join(",")}}`;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
// ../../packages/manifest-schema/src/semantic.ts
|
|
508
|
+
var RESERVED_ENV_NAMES = /* @__PURE__ */ new Set([
|
|
509
|
+
"TINY_APP_ID",
|
|
510
|
+
"TINY_APP_SLUG",
|
|
511
|
+
"TINY_DEPLOYMENT_ID",
|
|
512
|
+
"TINY_ENVIRONMENT",
|
|
513
|
+
"TINY_ORG_ID",
|
|
514
|
+
"TINY_REQUEST_ID",
|
|
515
|
+
"TINY_TOKEN",
|
|
516
|
+
"TINY_API_URL",
|
|
517
|
+
"TINY_DATABASE_URL",
|
|
518
|
+
"TINY_STORAGE_ROOT",
|
|
519
|
+
"TINY_BROKER_URL"
|
|
520
|
+
]);
|
|
521
|
+
function validateSemantics(manifest, context = {}) {
|
|
522
|
+
const policy = context.policy ?? DEFAULT_ORGANIZATION_POLICY;
|
|
523
|
+
const issues = [];
|
|
524
|
+
const add = (path, message, keyword = "semantic") => issues.push({ path, message, keyword });
|
|
525
|
+
if (manifest.runtime.type === "worker" && manifest.runtime.language === "python") {
|
|
526
|
+
add("/runtime/language", "worker runtime supports only typescript and javascript");
|
|
527
|
+
}
|
|
528
|
+
if (manifest.runtime.resources.memoryMiB > policy.maxMemoryMiB) {
|
|
529
|
+
add("/runtime/resources/memory", `exceeds the organization limit of ${policy.maxMemoryMiB}Mi`);
|
|
530
|
+
}
|
|
531
|
+
if (manifest.access.visibility === "public" && !policy.allowPublicApps) {
|
|
532
|
+
add("/access/visibility", "public apps are not permitted by organization policy");
|
|
533
|
+
}
|
|
534
|
+
if (manifest.access.visibility !== "public" && !manifest.access.requireLogin) {
|
|
535
|
+
add("/access/requireLogin", "non-public apps must require login");
|
|
536
|
+
}
|
|
537
|
+
const seenSubjects = /* @__PURE__ */ new Set();
|
|
538
|
+
manifest.access.subjects.forEach((subject, index) => {
|
|
539
|
+
const key = `${subject.type}:${subject.value}`;
|
|
540
|
+
if (seenSubjects.has(key)) add(`/access/subjects/${index}`, `duplicate subject ${key}`);
|
|
541
|
+
seenSubjects.add(key);
|
|
542
|
+
});
|
|
543
|
+
if (policy.reservedSlugs.includes(manifest.metadata.name)) {
|
|
544
|
+
add("/metadata/name", `"${manifest.metadata.name}" is a reserved name`);
|
|
545
|
+
}
|
|
546
|
+
manifest.secrets.forEach((secret, index) => {
|
|
547
|
+
if (RESERVED_ENV_NAMES.has(secret.name)) {
|
|
548
|
+
add(`/secrets/${index}/name`, `"${secret.name}" collides with a reserved platform variable`);
|
|
549
|
+
}
|
|
550
|
+
});
|
|
551
|
+
const secretNames = /* @__PURE__ */ new Set();
|
|
552
|
+
manifest.secrets.forEach((secret, index) => {
|
|
553
|
+
if (secretNames.has(secret.name)) add(`/secrets/${index}/name`, `duplicate secret name "${secret.name}"`);
|
|
554
|
+
secretNames.add(secret.name);
|
|
555
|
+
});
|
|
556
|
+
if (manifest.secrets.length > 0 && !policy.allowRawSecrets) {
|
|
557
|
+
add("/secrets", "raw secret injection is disabled by organization policy");
|
|
558
|
+
}
|
|
559
|
+
const { sleepAfterMs, archiveAfterUnusedMs, deleteAfterArchivedMs, previewTtlMs } = manifest.lifecycle;
|
|
560
|
+
if (sleepAfterMs !== null && archiveAfterUnusedMs !== null && archiveAfterUnusedMs <= sleepAfterMs) {
|
|
561
|
+
add("/lifecycle/archiveAfterUnused", "must be longer than lifecycle.sleepAfter");
|
|
562
|
+
}
|
|
563
|
+
if (deleteAfterArchivedMs !== null && archiveAfterUnusedMs === null) {
|
|
564
|
+
add("/lifecycle/deleteAfterArchived", "requires lifecycle.archiveAfterUnused to be set");
|
|
565
|
+
}
|
|
566
|
+
if (previewTtlMs > policy.maxPreviewTtlHours * 36e5) {
|
|
567
|
+
add("/lifecycle/previewTtl", `exceeds the organization maximum of ${policy.maxPreviewTtlHours}h`);
|
|
568
|
+
}
|
|
569
|
+
const storageNames = /* @__PURE__ */ new Set();
|
|
570
|
+
manifest.resources.storage.forEach((store, index) => {
|
|
571
|
+
if (storageNames.has(store.name)) add(`/resources/storage/${index}/name`, `duplicate storage name "${store.name}"`);
|
|
572
|
+
storageNames.add(store.name);
|
|
573
|
+
});
|
|
574
|
+
const capabilityNames = /* @__PURE__ */ new Set();
|
|
575
|
+
manifest.capabilities.forEach((capability, index) => {
|
|
576
|
+
if (capabilityNames.has(capability.name)) {
|
|
577
|
+
add(`/capabilities/${index}/name`, `duplicate capability name "${capability.name}"`);
|
|
578
|
+
}
|
|
579
|
+
capabilityNames.add(capability.name);
|
|
580
|
+
for (const operation of capability.allow) {
|
|
581
|
+
if (operation.endsWith(".*") || operation.includes("request") && operation.includes("raw")) {
|
|
582
|
+
add(`/capabilities/${index}/allow`, `"${operation}" is too broad; grant named operations only`);
|
|
583
|
+
}
|
|
584
|
+
}
|
|
585
|
+
});
|
|
586
|
+
if (manifest.network.egress.mode === "deny" && manifest.network.egress.allow.length > 0) {
|
|
587
|
+
add("/network/egress/mode", 'set mode to "allowlist" to use network.egress.allow');
|
|
588
|
+
}
|
|
589
|
+
if (manifest.network.egress.mode === "allowlist" && manifest.network.egress.allow.length === 0) {
|
|
590
|
+
add("/network/egress/allow", "allowlist mode requires at least one allowed host");
|
|
591
|
+
}
|
|
592
|
+
if (context.sourceFiles) {
|
|
593
|
+
if (!manifest.build.command && !context.sourceFiles.includes(manifest.runtime.entrypoint)) {
|
|
594
|
+
add("/runtime/entrypoint", `"${manifest.runtime.entrypoint}" was not found in the uploaded source`);
|
|
595
|
+
}
|
|
596
|
+
const migrations = manifest.resources.database?.migrations;
|
|
597
|
+
if (migrations && !context.sourceFiles.some((file) => file.startsWith(`${migrations}/`))) {
|
|
598
|
+
add("/resources/database/migrations", `no migration files found under "${migrations}/"`);
|
|
599
|
+
}
|
|
600
|
+
}
|
|
601
|
+
return issues;
|
|
602
|
+
}
|
|
603
|
+
function manifestWarnings(manifest) {
|
|
604
|
+
const warnings = [];
|
|
605
|
+
if (manifest.secrets.length > 0) {
|
|
606
|
+
warnings.push(`This app receives ${manifest.secrets.length} raw secret(s). Prefer a capability grant where one exists.`);
|
|
607
|
+
}
|
|
608
|
+
if (manifest.lifecycle.archiveAfterUnusedMs === null) {
|
|
609
|
+
warnings.push("No lifecycle.archiveAfterUnused set; this app will never be recommended for archive.");
|
|
610
|
+
}
|
|
611
|
+
if (manifest.access.visibility === "organization" && manifest.access.subjects.length <= 1) {
|
|
612
|
+
warnings.push('Visibility is "organization": every member can open this app.');
|
|
613
|
+
}
|
|
614
|
+
if (manifest.build.command && !manifest.build.output) {
|
|
615
|
+
warnings.push("build.command is set without build.output; the whole source tree, including node_modules, will be deployed.");
|
|
616
|
+
}
|
|
617
|
+
if (manifest.build.command && !manifest.build.lockfileRequired) {
|
|
618
|
+
warnings.push("build.lockfileRequired is false; two deploys of the same source may install different dependencies.");
|
|
619
|
+
}
|
|
620
|
+
if (manifest.network.egress.mode === "allowlist") {
|
|
621
|
+
warnings.push(
|
|
622
|
+
`Direct egress to ${manifest.network.egress.allow.map((rule) => rule.host).join(", ")} bypasses the capability broker; the app holds any credential it needs for those hosts itself.`
|
|
623
|
+
);
|
|
624
|
+
}
|
|
625
|
+
return warnings;
|
|
626
|
+
}
|
|
627
|
+
|
|
628
|
+
// ../../packages/manifest-schema/src/yaml.ts
|
|
629
|
+
function fail(message, line, remediation = "Fix the manifest and re-run `tiny validate`.") {
|
|
630
|
+
throw new TinyError("MANIFEST_PARSE_FAILED", `${message} (line ${line})`, {
|
|
631
|
+
remediation,
|
|
632
|
+
details: { line }
|
|
633
|
+
});
|
|
634
|
+
}
|
|
635
|
+
function stripComment(text) {
|
|
636
|
+
let quote = null;
|
|
637
|
+
for (let i = 0; i < text.length; i++) {
|
|
638
|
+
const ch = text[i];
|
|
639
|
+
if (quote) {
|
|
640
|
+
if (ch === "\\" && quote === '"') i++;
|
|
641
|
+
else if (ch === quote) quote = null;
|
|
642
|
+
} else if (ch === '"' || ch === "'") {
|
|
643
|
+
quote = ch;
|
|
644
|
+
} else if (ch === "#" && (i === 0 || /\s/.test(text[i - 1]))) {
|
|
645
|
+
return text.slice(0, i);
|
|
646
|
+
}
|
|
647
|
+
}
|
|
648
|
+
return text;
|
|
649
|
+
}
|
|
650
|
+
function scan(source) {
|
|
651
|
+
const out = [];
|
|
652
|
+
const raw = source.split(/\r?\n/);
|
|
653
|
+
for (let i = 0; i < raw.length; i++) {
|
|
654
|
+
const original = raw[i];
|
|
655
|
+
if (original.includes(" ")) {
|
|
656
|
+
fail("Tabs are not allowed for indentation", i + 1, "Indent with spaces only.");
|
|
657
|
+
}
|
|
658
|
+
const text = stripComment(original).trimEnd();
|
|
659
|
+
if (text.trim() === "") continue;
|
|
660
|
+
if (text.trim() === "---") continue;
|
|
661
|
+
if (text.trim() === "...") break;
|
|
662
|
+
out.push({ indent: original.length - original.trimStart().length, text: text.trim(), number: i + 1 });
|
|
663
|
+
}
|
|
664
|
+
return out;
|
|
665
|
+
}
|
|
666
|
+
function parseFlowScalar(token, line) {
|
|
667
|
+
const text = token.trim();
|
|
668
|
+
if (text === "") return null;
|
|
669
|
+
if (text.startsWith('"')) {
|
|
670
|
+
if (!text.endsWith('"') || text.length < 2) fail("Unterminated double-quoted string", line);
|
|
671
|
+
return JSON.parse(text);
|
|
672
|
+
}
|
|
673
|
+
if (text.startsWith("'")) {
|
|
674
|
+
if (!text.endsWith("'") || text.length < 2) fail("Unterminated single-quoted string", line);
|
|
675
|
+
return text.slice(1, -1).replaceAll("''", "'");
|
|
676
|
+
}
|
|
677
|
+
if (text.startsWith("[") || text.startsWith("{")) return parseFlowCollection(text, line);
|
|
678
|
+
if (text === "null" || text === "~") return null;
|
|
679
|
+
if (text === "true") return true;
|
|
680
|
+
if (text === "false") return false;
|
|
681
|
+
if (/^-?(0|[1-9][0-9]*)(\.[0-9]+)?([eE][+-]?[0-9]+)?$/.test(text)) return Number(text);
|
|
682
|
+
if (text.startsWith("*") || text.startsWith("&") || text.startsWith("!")) {
|
|
683
|
+
fail("Anchors, aliases, and tags are not supported in tiny.yaml", line, "Write the value inline.");
|
|
684
|
+
}
|
|
685
|
+
return text;
|
|
686
|
+
}
|
|
687
|
+
function splitFlow(body, line) {
|
|
688
|
+
const parts = [];
|
|
689
|
+
let depth = 0;
|
|
690
|
+
let quote = null;
|
|
691
|
+
let current = "";
|
|
692
|
+
for (let i = 0; i < body.length; i++) {
|
|
693
|
+
const ch = body[i];
|
|
694
|
+
if (quote) {
|
|
695
|
+
current += ch;
|
|
696
|
+
if (ch === "\\" && quote === '"') {
|
|
697
|
+
current += body[++i] ?? "";
|
|
698
|
+
continue;
|
|
699
|
+
}
|
|
700
|
+
if (ch === quote) quote = null;
|
|
701
|
+
continue;
|
|
702
|
+
}
|
|
703
|
+
if (ch === '"' || ch === "'") {
|
|
704
|
+
quote = ch;
|
|
705
|
+
current += ch;
|
|
706
|
+
continue;
|
|
707
|
+
}
|
|
708
|
+
if (ch === "[" || ch === "{") depth++;
|
|
709
|
+
if (ch === "]" || ch === "}") depth--;
|
|
710
|
+
if (ch === "," && depth === 0) {
|
|
711
|
+
parts.push(current);
|
|
712
|
+
current = "";
|
|
713
|
+
continue;
|
|
714
|
+
}
|
|
715
|
+
current += ch;
|
|
716
|
+
}
|
|
717
|
+
if (quote) fail("Unterminated string in flow collection", line);
|
|
718
|
+
if (depth !== 0) fail("Unbalanced brackets in flow collection", line);
|
|
719
|
+
if (current.trim() !== "") parts.push(current);
|
|
720
|
+
return parts;
|
|
721
|
+
}
|
|
722
|
+
function parseFlowCollection(text, line) {
|
|
723
|
+
if (text.startsWith("[")) {
|
|
724
|
+
if (!text.endsWith("]")) fail("Unterminated flow sequence", line);
|
|
725
|
+
return splitFlow(text.slice(1, -1), line).map((part) => parseFlowScalar(part, line));
|
|
726
|
+
}
|
|
727
|
+
if (!text.endsWith("}")) fail("Unterminated flow mapping", line);
|
|
728
|
+
const out = {};
|
|
729
|
+
for (const part of splitFlow(text.slice(1, -1), line)) {
|
|
730
|
+
const idx = part.indexOf(":");
|
|
731
|
+
if (idx === -1) fail('Flow mapping entry is missing ":"', line);
|
|
732
|
+
const key = String(parseFlowScalar(part.slice(0, idx), line));
|
|
733
|
+
if (key in out) fail(`Duplicate key "${key}"`, line);
|
|
734
|
+
out[key] = parseFlowScalar(part.slice(idx + 1), line);
|
|
735
|
+
}
|
|
736
|
+
return out;
|
|
737
|
+
}
|
|
738
|
+
function keySeparator(text) {
|
|
739
|
+
let quote = null;
|
|
740
|
+
for (let i = 0; i < text.length; i++) {
|
|
741
|
+
const ch = text[i];
|
|
742
|
+
if (quote) {
|
|
743
|
+
if (ch === "\\" && quote === '"') i++;
|
|
744
|
+
else if (ch === quote) quote = null;
|
|
745
|
+
continue;
|
|
746
|
+
}
|
|
747
|
+
if (ch === '"' || ch === "'") {
|
|
748
|
+
quote = ch;
|
|
749
|
+
continue;
|
|
750
|
+
}
|
|
751
|
+
if (ch === "[" || ch === "{") return -1;
|
|
752
|
+
if (ch === ":" && (i === text.length - 1 || text[i + 1] === " ")) return i;
|
|
753
|
+
}
|
|
754
|
+
return -1;
|
|
755
|
+
}
|
|
756
|
+
var Parser = class {
|
|
757
|
+
index = 0;
|
|
758
|
+
lines;
|
|
759
|
+
constructor(lines) {
|
|
760
|
+
this.lines = lines;
|
|
761
|
+
}
|
|
762
|
+
peek() {
|
|
763
|
+
return this.lines[this.index];
|
|
764
|
+
}
|
|
765
|
+
parseValue(indent) {
|
|
766
|
+
const line = this.peek();
|
|
767
|
+
if (!line || line.indent < indent) return null;
|
|
768
|
+
if (line.text.startsWith("- ") || line.text === "-") return this.parseSequence(line.indent);
|
|
769
|
+
return this.parseMapping(line.indent);
|
|
770
|
+
}
|
|
771
|
+
parseSequence(indent) {
|
|
772
|
+
const items = [];
|
|
773
|
+
for (; ; ) {
|
|
774
|
+
const line = this.peek();
|
|
775
|
+
if (!line || line.indent !== indent || !(line.text === "-" || line.text.startsWith("- "))) break;
|
|
776
|
+
const rest = line.text === "-" ? "" : line.text.slice(2).trim();
|
|
777
|
+
this.index++;
|
|
778
|
+
if (rest === "") {
|
|
779
|
+
items.push(this.parseValue(indent + 1));
|
|
780
|
+
continue;
|
|
781
|
+
}
|
|
782
|
+
const sep2 = keySeparator(rest);
|
|
783
|
+
if (sep2 !== -1) {
|
|
784
|
+
items.push(this.parseInlineMapping(line, rest, sep2, indent));
|
|
785
|
+
} else {
|
|
786
|
+
items.push(this.parseScalarOrBlock(rest, line));
|
|
787
|
+
}
|
|
788
|
+
}
|
|
789
|
+
return items;
|
|
790
|
+
}
|
|
791
|
+
parseInlineMapping(line, rest, sep2, indent) {
|
|
792
|
+
const map = {};
|
|
793
|
+
const key = String(parseFlowScalar(rest.slice(0, sep2), line.number));
|
|
794
|
+
const inlineValue = rest.slice(sep2 + 1).trim();
|
|
795
|
+
const childIndent = indent + 2;
|
|
796
|
+
if (inlineValue === "") {
|
|
797
|
+
map[key] = this.hasChildAt(childIndent) ? this.parseValue(childIndent) : null;
|
|
798
|
+
} else {
|
|
799
|
+
map[key] = this.parseScalarOrBlock(inlineValue, line);
|
|
800
|
+
}
|
|
801
|
+
const tail = this.parseMappingEntries(childIndent);
|
|
802
|
+
for (const [k, v] of Object.entries(tail)) {
|
|
803
|
+
if (k in map) fail(`Duplicate key "${k}"`, line.number);
|
|
804
|
+
map[k] = v;
|
|
805
|
+
}
|
|
806
|
+
return map;
|
|
807
|
+
}
|
|
808
|
+
hasChildAt(indent) {
|
|
809
|
+
const next = this.peek();
|
|
810
|
+
return next !== void 0 && next.indent >= indent;
|
|
811
|
+
}
|
|
812
|
+
parseMapping(indent) {
|
|
813
|
+
return this.parseMappingEntries(indent);
|
|
814
|
+
}
|
|
815
|
+
parseMappingEntries(indent) {
|
|
816
|
+
const map = {};
|
|
817
|
+
for (; ; ) {
|
|
818
|
+
const line = this.peek();
|
|
819
|
+
if (!line || line.indent < indent) break;
|
|
820
|
+
if (line.indent > indent) fail("Unexpected indentation", line.number, "Align keys of the same mapping to the same column.");
|
|
821
|
+
if (line.text.startsWith("- ")) break;
|
|
822
|
+
const sep2 = keySeparator(line.text);
|
|
823
|
+
if (sep2 === -1) fail(`Expected "key: value" but found "${line.text}"`, line.number);
|
|
824
|
+
const key = String(parseFlowScalar(line.text.slice(0, sep2), line.number));
|
|
825
|
+
if (key in map) fail(`Duplicate key "${key}"`, line.number, "Remove the repeated key.");
|
|
826
|
+
const inline = line.text.slice(sep2 + 1).trim();
|
|
827
|
+
this.index++;
|
|
828
|
+
if (inline === "") {
|
|
829
|
+
const next = this.peek();
|
|
830
|
+
map[key] = next && next.indent > indent ? this.parseValue(next.indent) : next && next.indent === indent && next.text.startsWith("- ") ? this.parseSequence(indent) : null;
|
|
831
|
+
} else {
|
|
832
|
+
map[key] = this.parseScalarOrBlock(inline, line);
|
|
833
|
+
}
|
|
834
|
+
}
|
|
835
|
+
return map;
|
|
836
|
+
}
|
|
837
|
+
/** Handle `|`/`>` block scalars; everything else is a flow scalar. */
|
|
838
|
+
parseScalarOrBlock(text, line) {
|
|
839
|
+
if (text !== "|" && text !== ">" && text !== "|-" && text !== ">-") {
|
|
840
|
+
return parseFlowScalar(text, line.number);
|
|
841
|
+
}
|
|
842
|
+
const folded = text.startsWith(">");
|
|
843
|
+
const chomp = text.endsWith("-");
|
|
844
|
+
const parts = [];
|
|
845
|
+
const baseIndent = this.peek()?.indent ?? 0;
|
|
846
|
+
while (this.peek() && this.peek().indent >= baseIndent && baseIndent > line.indent) {
|
|
847
|
+
parts.push(this.lines[this.index].text);
|
|
848
|
+
this.index++;
|
|
849
|
+
}
|
|
850
|
+
const body = folded ? parts.join(" ") : parts.join("\n");
|
|
851
|
+
return chomp ? body : body + (folded ? "" : "\n");
|
|
852
|
+
}
|
|
853
|
+
atEnd() {
|
|
854
|
+
return this.index >= this.lines.length;
|
|
855
|
+
}
|
|
856
|
+
currentLine() {
|
|
857
|
+
return this.peek()?.number ?? 0;
|
|
858
|
+
}
|
|
859
|
+
};
|
|
860
|
+
function parseYaml(source) {
|
|
861
|
+
const lines = scan(source);
|
|
862
|
+
if (lines.length === 0) {
|
|
863
|
+
throw new TinyError("MANIFEST_PARSE_FAILED", "The manifest is empty.", {
|
|
864
|
+
remediation: "Run `tiny init` to generate a starter tiny.yaml."
|
|
865
|
+
});
|
|
866
|
+
}
|
|
867
|
+
const parser = new Parser(lines);
|
|
868
|
+
const value = parser.parseValue(lines[0].indent);
|
|
869
|
+
if (!parser.atEnd()) fail("Trailing content after the document", parser.currentLine());
|
|
870
|
+
return value;
|
|
871
|
+
}
|
|
872
|
+
|
|
873
|
+
// ../../packages/manifest-schema/src/index.ts
|
|
874
|
+
var SCHEMA_PATH = join(dirname(fileURLToPath(import.meta.url)), "..", "schema", "tiny.v1alpha1.json");
|
|
875
|
+
var manifestSchema = JSON.parse(readFileSync(SCHEMA_PATH, "utf8"));
|
|
876
|
+
var validator = null;
|
|
877
|
+
function schemaValidator() {
|
|
878
|
+
validator ??= new SchemaValidator(manifestSchema);
|
|
879
|
+
return validator;
|
|
880
|
+
}
|
|
881
|
+
function formatIssues(issues) {
|
|
882
|
+
return issues.slice(0, 10).map((issue) => `${issue.path === "" ? "(root)" : issue.path} ${issue.message}`).join("; ");
|
|
883
|
+
}
|
|
884
|
+
function parseManifest(source, context = {}) {
|
|
885
|
+
const document = parseYaml(source);
|
|
886
|
+
if (typeof document !== "object" || document === null || Array.isArray(document)) {
|
|
887
|
+
throw new TinyError("MANIFEST_PARSE_FAILED", "tiny.yaml must contain a mapping at the top level.", {
|
|
888
|
+
remediation: "Run `tiny init` to generate a starter manifest."
|
|
889
|
+
});
|
|
890
|
+
}
|
|
891
|
+
const apiVersion = document.apiVersion;
|
|
892
|
+
if (apiVersion !== MANIFEST_API_VERSION) {
|
|
893
|
+
throw new TinyError("MANIFEST_UNSUPPORTED_API_VERSION", `Unsupported apiVersion ${JSON.stringify(apiVersion)}.`, {
|
|
894
|
+
remediation: `Set apiVersion to "${MANIFEST_API_VERSION}", or run \`tiny manifest upgrade\`.`,
|
|
895
|
+
details: { supported: [MANIFEST_API_VERSION], found: apiVersion ?? null }
|
|
896
|
+
});
|
|
897
|
+
}
|
|
898
|
+
const schemaIssues = schemaValidator().validate(document);
|
|
899
|
+
if (schemaIssues.length > 0) {
|
|
900
|
+
throw new TinyError("MANIFEST_SCHEMA_INVALID", `tiny.yaml failed schema validation: ${formatIssues(schemaIssues)}`, {
|
|
901
|
+
details: { issues: schemaIssues }
|
|
902
|
+
});
|
|
903
|
+
}
|
|
904
|
+
const manifest = document;
|
|
905
|
+
const normalized = normalizeManifest(manifest);
|
|
906
|
+
const semanticIssues = validateSemantics(normalized, context);
|
|
907
|
+
if (semanticIssues.length > 0) {
|
|
908
|
+
throw new TinyError("MANIFEST_POLICY_VIOLATION", `tiny.yaml violates platform or organization rules: ${formatIssues(semanticIssues)}`, {
|
|
909
|
+
remediation: "Fix the fields listed in details.issues, or ask an admin to change organization policy.",
|
|
910
|
+
details: { issues: semanticIssues }
|
|
911
|
+
});
|
|
912
|
+
}
|
|
913
|
+
return {
|
|
914
|
+
source,
|
|
915
|
+
manifest,
|
|
916
|
+
normalized,
|
|
917
|
+
sha256: manifestSha256(normalized),
|
|
918
|
+
warnings: manifestWarnings(normalized)
|
|
919
|
+
};
|
|
920
|
+
}
|
|
921
|
+
function manifestSha256(normalized) {
|
|
922
|
+
return createHash("sha256").update(canonicalize(normalized)).digest("hex");
|
|
923
|
+
}
|
|
924
|
+
function starterManifest(name, owner) {
|
|
925
|
+
return `apiVersion: ${MANIFEST_API_VERSION}
|
|
926
|
+
kind: App
|
|
927
|
+
|
|
928
|
+
metadata:
|
|
929
|
+
name: ${name}
|
|
930
|
+
owner: ${owner}
|
|
931
|
+
|
|
932
|
+
runtime:
|
|
933
|
+
type: worker
|
|
934
|
+
language: typescript
|
|
935
|
+
entrypoint: src/index.ts
|
|
936
|
+
|
|
937
|
+
access:
|
|
938
|
+
visibility: private
|
|
939
|
+
requireLogin: true
|
|
940
|
+
|
|
941
|
+
lifecycle:
|
|
942
|
+
sleepAfter: 60m
|
|
943
|
+
archiveAfterUnused: 30d
|
|
944
|
+
`;
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// ../../packages/orchestrator/src/artifacts.ts
|
|
948
|
+
import { createHash as createHash2, randomUUID } from "node:crypto";
|
|
949
|
+
import { cpSync, existsSync, mkdirSync, readFileSync as readFileSync2, readdirSync, renameSync, rmSync, statSync, writeFileSync } from "node:fs";
|
|
950
|
+
import { dirname as dirname2, join as join2, normalize, relative, sep } from "node:path";
|
|
951
|
+
var DEFAULT_IGNORES = [
|
|
952
|
+
".git",
|
|
953
|
+
"node_modules",
|
|
954
|
+
".tiny",
|
|
955
|
+
".output",
|
|
956
|
+
"dist/.cache",
|
|
957
|
+
".DS_Store",
|
|
958
|
+
".env",
|
|
959
|
+
".env.local",
|
|
960
|
+
".env.production",
|
|
961
|
+
"*.pem",
|
|
962
|
+
"*.key",
|
|
963
|
+
"id_rsa",
|
|
964
|
+
".venv",
|
|
965
|
+
"__pycache__",
|
|
966
|
+
".pytest_cache",
|
|
967
|
+
".turbo",
|
|
968
|
+
"coverage"
|
|
969
|
+
];
|
|
970
|
+
var SECRET_LOOKING = /(^|\/)(\.env(\..+)?|.*\.pem|.*\.key|id_rsa|credentials\.json|service-account.*\.json)$/i;
|
|
971
|
+
var DEFAULT_LIMITS = {
|
|
972
|
+
maxFiles: 5e3,
|
|
973
|
+
maxTotalBytes: 50 * 1024 * 1024,
|
|
974
|
+
maxFileBytes: 10 * 1024 * 1024
|
|
975
|
+
};
|
|
976
|
+
function loadIgnores(root) {
|
|
977
|
+
const patterns = [...DEFAULT_IGNORES];
|
|
978
|
+
const file = join2(root, ".tinyignore");
|
|
979
|
+
if (existsSync(file)) {
|
|
980
|
+
for (const line of readFileSync2(file, "utf8").split("\n")) {
|
|
981
|
+
const trimmed = line.trim();
|
|
982
|
+
if (trimmed && !trimmed.startsWith("#")) patterns.push(trimmed);
|
|
983
|
+
}
|
|
984
|
+
}
|
|
985
|
+
return patterns;
|
|
986
|
+
}
|
|
987
|
+
function matches(pattern, path) {
|
|
988
|
+
if (pattern.includes("*")) {
|
|
989
|
+
const rule = new RegExp(`^${pattern.split("*").map(escapeRegExp).join("[^/]*")}$`);
|
|
990
|
+
return path.split("/").some((segment) => rule.test(segment)) || rule.test(path);
|
|
991
|
+
}
|
|
992
|
+
return path === pattern || path.startsWith(`${pattern}/`) || path.split("/").includes(pattern);
|
|
993
|
+
}
|
|
994
|
+
function escapeRegExp(value) {
|
|
995
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
996
|
+
}
|
|
997
|
+
function packSource(root, limits = DEFAULT_LIMITS) {
|
|
998
|
+
const ignores = loadIgnores(root);
|
|
999
|
+
const files = [];
|
|
1000
|
+
const skippedSecrets = [];
|
|
1001
|
+
let sizeBytes = 0;
|
|
1002
|
+
const walk = (directory) => {
|
|
1003
|
+
for (const entry of readdirSync(directory, { withFileTypes: true }).sort((a, b) => a.name < b.name ? -1 : 1)) {
|
|
1004
|
+
const absolute = join2(directory, entry.name);
|
|
1005
|
+
const path = relative(root, absolute).split(sep).join("/");
|
|
1006
|
+
if (SECRET_LOOKING.test(path)) {
|
|
1007
|
+
skippedSecrets.push(path);
|
|
1008
|
+
continue;
|
|
1009
|
+
}
|
|
1010
|
+
if (ignores.some((pattern) => matches(pattern, path))) {
|
|
1011
|
+
continue;
|
|
1012
|
+
}
|
|
1013
|
+
if (entry.isSymbolicLink()) continue;
|
|
1014
|
+
if (entry.isDirectory()) {
|
|
1015
|
+
walk(absolute);
|
|
1016
|
+
continue;
|
|
1017
|
+
}
|
|
1018
|
+
const size = statSync(absolute).size;
|
|
1019
|
+
if (size > limits.maxFileBytes) {
|
|
1020
|
+
throw new TinyError("RESOURCE_LIMIT_EXCEEDED", `File ${path} is larger than the ${limits.maxFileBytes} byte limit.`, {
|
|
1021
|
+
remediation: "Add the file to .tinyignore, or store large assets in object storage instead.",
|
|
1022
|
+
details: { path, size }
|
|
1023
|
+
});
|
|
1024
|
+
}
|
|
1025
|
+
files.push(path);
|
|
1026
|
+
sizeBytes += size;
|
|
1027
|
+
if (files.length > limits.maxFiles || sizeBytes > limits.maxTotalBytes) {
|
|
1028
|
+
throw new TinyError("RESOURCE_LIMIT_EXCEEDED", "Source upload exceeds the size limits for an app.", {
|
|
1029
|
+
remediation: "Add build output and vendored dependencies to .tinyignore.",
|
|
1030
|
+
details: { files: files.length, sizeBytes, limits }
|
|
1031
|
+
});
|
|
1032
|
+
}
|
|
1033
|
+
}
|
|
1034
|
+
};
|
|
1035
|
+
walk(root);
|
|
1036
|
+
const hash = createHash2("sha256");
|
|
1037
|
+
for (const path of files) {
|
|
1038
|
+
hash.update(path);
|
|
1039
|
+
hash.update(readFileSync2(join2(root, path)));
|
|
1040
|
+
}
|
|
1041
|
+
return { files, sizeBytes, sha256: hash.digest("hex"), skippedSecrets };
|
|
1042
|
+
}
|
|
1043
|
+
function createArtifactUpload(sourceRoot, packed = packSource(sourceRoot)) {
|
|
1044
|
+
return {
|
|
1045
|
+
sha256: packed.sha256,
|
|
1046
|
+
files: packed.files.map((path) => ({ path, content: readFileSync2(join2(sourceRoot, path)).toString("base64") }))
|
|
1047
|
+
};
|
|
1048
|
+
}
|
|
1049
|
+
|
|
1050
|
+
// ../../packages/resource-sqlite/src/index.ts
|
|
1051
|
+
import { DatabaseSync } from "node:sqlite";
|
|
1052
|
+
|
|
1053
|
+
// ../../packages/runtime-provider/src/contract.ts
|
|
1054
|
+
import test from "node:test";
|
|
1055
|
+
|
|
1056
|
+
// ../../packages/orchestrator/src/planner.ts
|
|
1057
|
+
var PLAN_TTL_MS = 15 * 60 * 1e3;
|
|
1058
|
+
|
|
1059
|
+
// ../../packages/auth/src/tokens.ts
|
|
1060
|
+
var TOKEN_TTL = {
|
|
1061
|
+
session: 60 * 60 * 12,
|
|
1062
|
+
appUser: 60 * 4,
|
|
1063
|
+
appWorkload: 60 * 10,
|
|
1064
|
+
runtimeAgent: 60 * 15,
|
|
1065
|
+
cli: 60 * 60 * 24 * 30
|
|
1066
|
+
};
|
|
1067
|
+
|
|
1068
|
+
// ../../packages/orchestrator/src/data.ts
|
|
1069
|
+
import { DatabaseSync as DatabaseSync2 } from "node:sqlite";
|
|
1070
|
+
var DATA_LIMITS = {
|
|
1071
|
+
maxSqlBytes: 1e5,
|
|
1072
|
+
maxParameters: 256,
|
|
1073
|
+
maxRows: 1e4,
|
|
1074
|
+
maxResultBytes: 8 * 1024 * 1024,
|
|
1075
|
+
transactionTtlMs: 15e3,
|
|
1076
|
+
/**
|
|
1077
|
+
* Postgres serves concurrent transactions from its pool. SQLite has exactly
|
|
1078
|
+
* one writer per database, and pretending otherwise would just move the
|
|
1079
|
+
* failure to a lock timeout in the middle of someone's request.
|
|
1080
|
+
*/
|
|
1081
|
+
maxOpenTransactionsPerEnvironment: 4,
|
|
1082
|
+
maxOpenTransactionsPerSqliteEnvironment: 1,
|
|
1083
|
+
maxConcurrentStatementsPerEnvironment: 16
|
|
1084
|
+
};
|
|
1085
|
+
|
|
1086
|
+
// ../../packages/orchestrator/src/storage.ts
|
|
1087
|
+
var STORAGE_LIMITS = {
|
|
1088
|
+
maxObjectBytes: 32 * 1024 * 1024,
|
|
1089
|
+
maxKeyBytes: 1024,
|
|
1090
|
+
/** One listing page. A namespace may hold more; the reply says so. */
|
|
1091
|
+
maxListKeys: 1e3,
|
|
1092
|
+
maxConcurrentOperationsPerEnvironment: 16
|
|
1093
|
+
};
|
|
1094
|
+
|
|
1095
|
+
// ../../packages/db/src/store.ts
|
|
1096
|
+
import { dirname as dirname3, join as join3 } from "node:path";
|
|
1097
|
+
import { fileURLToPath as fileURLToPath2 } from "node:url";
|
|
1098
|
+
|
|
1099
|
+
// ../../packages/db/src/sqlite.ts
|
|
1100
|
+
import { DatabaseSync as DatabaseSync3 } from "node:sqlite";
|
|
1101
|
+
|
|
1102
|
+
// ../../packages/db/src/store.ts
|
|
1103
|
+
var MIGRATIONS_DIR = join3(dirname3(fileURLToPath2(import.meta.url)), "..", "migrations");
|
|
1104
|
+
|
|
1105
|
+
// ../cli/src/client.ts
|
|
1106
|
+
var ApiClient = class {
|
|
1107
|
+
apiUrl;
|
|
1108
|
+
token;
|
|
1109
|
+
timeoutMs;
|
|
1110
|
+
constructor(options) {
|
|
1111
|
+
this.apiUrl = options.apiUrl.replace(/\/$/, "");
|
|
1112
|
+
this.token = options.token;
|
|
1113
|
+
this.timeoutMs = options.timeoutMs ?? 12e4;
|
|
1114
|
+
}
|
|
1115
|
+
async request(method, path, options = {}) {
|
|
1116
|
+
const url = new URL(this.apiUrl + path);
|
|
1117
|
+
for (const [key, value] of Object.entries(options.query ?? {})) {
|
|
1118
|
+
if (value !== void 0) url.searchParams.set(key, String(value));
|
|
1119
|
+
}
|
|
1120
|
+
const headers = { accept: "application/json" };
|
|
1121
|
+
if (this.token) headers.authorization = `Bearer ${this.token}`;
|
|
1122
|
+
if (options.body !== void 0) headers["content-type"] = "application/json";
|
|
1123
|
+
if (options.idempotencyKey) headers["idempotency-key"] = options.idempotencyKey;
|
|
1124
|
+
let response;
|
|
1125
|
+
try {
|
|
1126
|
+
response = await fetch(url, {
|
|
1127
|
+
method,
|
|
1128
|
+
headers,
|
|
1129
|
+
...options.body !== void 0 ? { body: JSON.stringify(options.body) } : {},
|
|
1130
|
+
signal: AbortSignal.timeout(this.timeoutMs)
|
|
1131
|
+
});
|
|
1132
|
+
} catch (error) {
|
|
1133
|
+
throw new TinyError("PROVIDER_UNAVAILABLE", `Could not reach the control plane at ${this.apiUrl}.`, {
|
|
1134
|
+
remediation: "Check that the API is running and that `tiny login --api` points at it.",
|
|
1135
|
+
retryable: true,
|
|
1136
|
+
cause: error
|
|
1137
|
+
});
|
|
1138
|
+
}
|
|
1139
|
+
if (response.status === 204) return void 0;
|
|
1140
|
+
const payload = await response.json().catch(() => ({}));
|
|
1141
|
+
if (!response.ok) {
|
|
1142
|
+
const error = payload.error;
|
|
1143
|
+
throw new TinyError(error?.code ?? "INTERNAL", error?.message ?? `Request failed with ${response.status}.`, {
|
|
1144
|
+
...error?.remediation ? { remediation: error.remediation } : {},
|
|
1145
|
+
...error?.retryable !== void 0 ? { retryable: error.retryable } : {},
|
|
1146
|
+
...error?.details ? { details: error.details } : {},
|
|
1147
|
+
...response.headers.get("x-request-id") ? { requestId: response.headers.get("x-request-id") } : {}
|
|
1148
|
+
});
|
|
1149
|
+
}
|
|
1150
|
+
return payload;
|
|
1151
|
+
}
|
|
1152
|
+
get(path, query) {
|
|
1153
|
+
return this.request("GET", path, query ? { query } : {});
|
|
1154
|
+
}
|
|
1155
|
+
post(path, body, idempotencyKey) {
|
|
1156
|
+
return this.request("POST", path, {
|
|
1157
|
+
...body !== void 0 ? { body } : {},
|
|
1158
|
+
...idempotencyKey ? { idempotencyKey } : {}
|
|
1159
|
+
});
|
|
1160
|
+
}
|
|
1161
|
+
put(path, body) {
|
|
1162
|
+
return this.request("PUT", path, body !== void 0 ? { body } : {});
|
|
1163
|
+
}
|
|
1164
|
+
del(path, query) {
|
|
1165
|
+
return this.request("DELETE", path, query ? { query } : {});
|
|
1166
|
+
}
|
|
1167
|
+
uploadDirectory(directory) {
|
|
1168
|
+
return this.post("/v1/artifacts", createArtifactUpload(directory));
|
|
1169
|
+
}
|
|
1170
|
+
};
|
|
1171
|
+
|
|
1172
|
+
// ../cli/src/config.ts
|
|
1173
|
+
import { chmodSync, existsSync as existsSync2, mkdirSync as mkdirSync2, readFileSync as readFileSync3, writeFileSync as writeFileSync2 } from "node:fs";
|
|
1174
|
+
import { homedir } from "node:os";
|
|
1175
|
+
import { dirname as dirname4, join as join4 } from "node:path";
|
|
1176
|
+
var EMPTY = { version: 1, current: null, profiles: {} };
|
|
1177
|
+
function configPath() {
|
|
1178
|
+
return process.env.TINY_CONFIG ?? join4(homedir(), ".tiny", "credentials.json");
|
|
1179
|
+
}
|
|
1180
|
+
function loadConfig() {
|
|
1181
|
+
const path = configPath();
|
|
1182
|
+
if (!existsSync2(path)) return { ...EMPTY, profiles: {} };
|
|
1183
|
+
try {
|
|
1184
|
+
return JSON.parse(readFileSync3(path, "utf8"));
|
|
1185
|
+
} catch {
|
|
1186
|
+
return { ...EMPTY, profiles: {} };
|
|
1187
|
+
}
|
|
1188
|
+
}
|
|
1189
|
+
function saveConfig(config) {
|
|
1190
|
+
const path = configPath();
|
|
1191
|
+
mkdirSync2(dirname4(path), { recursive: true });
|
|
1192
|
+
writeFileSync2(path, `${JSON.stringify(config, null, 2)}
|
|
1193
|
+
`, { mode: 384 });
|
|
1194
|
+
chmodSync(path, 384);
|
|
1195
|
+
}
|
|
1196
|
+
function currentProfile() {
|
|
1197
|
+
const config = loadConfig();
|
|
1198
|
+
if (!config.current) return null;
|
|
1199
|
+
return config.profiles[config.current] ?? null;
|
|
1200
|
+
}
|
|
1201
|
+
function setProfile(name, credentials) {
|
|
1202
|
+
const config = loadConfig();
|
|
1203
|
+
config.profiles[name] = credentials;
|
|
1204
|
+
config.current = name;
|
|
1205
|
+
saveConfig(config);
|
|
1206
|
+
}
|
|
1207
|
+
function clearProfile(name) {
|
|
1208
|
+
const config = loadConfig();
|
|
1209
|
+
const target = name ?? config.current;
|
|
1210
|
+
if (target) delete config.profiles[target];
|
|
1211
|
+
if (config.current === target) config.current = Object.keys(config.profiles)[0] ?? null;
|
|
1212
|
+
saveConfig(config);
|
|
1213
|
+
}
|
|
1214
|
+
|
|
1215
|
+
// ../cli/src/deployments.ts
|
|
1216
|
+
var TERMINAL_STATUSES = ["ready", "failed", "superseded", "rolled_back"];
|
|
1217
|
+
var DEFAULT_WAIT_MS = 3e5;
|
|
1218
|
+
async function awaitDeployment(fetchDeployment, options = {}) {
|
|
1219
|
+
const timeoutMs = options.timeoutMs ?? DEFAULT_WAIT_MS;
|
|
1220
|
+
const pollMs = options.pollMs ?? 500;
|
|
1221
|
+
const deadline = Date.now() + timeoutMs;
|
|
1222
|
+
let reported = "";
|
|
1223
|
+
for (; ; ) {
|
|
1224
|
+
const record = await fetchDeployment();
|
|
1225
|
+
if (record.status !== reported) {
|
|
1226
|
+
reported = record.status;
|
|
1227
|
+
options.onStatus?.(record.status);
|
|
1228
|
+
}
|
|
1229
|
+
if (TERMINAL_STATUSES.includes(record.status)) return record;
|
|
1230
|
+
if (Date.now() >= deadline) return null;
|
|
1231
|
+
await new Promise((resolve2) => setTimeout(resolve2, pollMs));
|
|
1232
|
+
}
|
|
1233
|
+
}
|
|
1234
|
+
function buildSummary(providerState) {
|
|
1235
|
+
if (!providerState || typeof providerState.builtArtifactUri !== "string") return void 0;
|
|
1236
|
+
return {
|
|
1237
|
+
artifactUri: providerState.builtArtifactUri,
|
|
1238
|
+
cached: providerState.buildCached === true,
|
|
1239
|
+
durationMs: Number(providerState.buildMs ?? 0)
|
|
1240
|
+
};
|
|
1241
|
+
}
|
|
1242
|
+
function describeBuild(summary) {
|
|
1243
|
+
if (!summary) return null;
|
|
1244
|
+
return summary.cached ? `Reused a cached build (${summary.artifactUri})` : `Built in ${(summary.durationMs / 1e3).toFixed(1)}s (${summary.artifactUri})`;
|
|
1245
|
+
}
|
|
1246
|
+
function describeFailure(record) {
|
|
1247
|
+
const message = record?.errorDetail?.message;
|
|
1248
|
+
if (typeof message === "string" && message !== "") return message;
|
|
1249
|
+
return record?.errorCode ?? "no reason was recorded";
|
|
1250
|
+
}
|
|
1251
|
+
function failureRemediation(record) {
|
|
1252
|
+
const remediation = record?.errorDetail?.remediation;
|
|
1253
|
+
return typeof remediation === "string" && remediation !== "" ? remediation : "Read `tiny logs` for the failure, fix it, and deploy again.";
|
|
1254
|
+
}
|
|
1255
|
+
|
|
1256
|
+
// ../cli/src/output.ts
|
|
1257
|
+
var CLI_API_VERSION = "cli.tinycloud.dev/v1";
|
|
1258
|
+
var EXIT = {
|
|
1259
|
+
ok: 0,
|
|
1260
|
+
error: 1,
|
|
1261
|
+
usage: 2,
|
|
1262
|
+
auth: 3,
|
|
1263
|
+
validation: 4,
|
|
1264
|
+
policy: 5,
|
|
1265
|
+
notFound: 6,
|
|
1266
|
+
interactionRequired: 7,
|
|
1267
|
+
conflict: 8,
|
|
1268
|
+
unavailable: 9
|
|
1269
|
+
};
|
|
1270
|
+
var EXIT_BY_CODE = {
|
|
1271
|
+
UNAUTHENTICATED: EXIT.auth,
|
|
1272
|
+
FORBIDDEN: EXIT.policy,
|
|
1273
|
+
NOT_FOUND: EXIT.notFound,
|
|
1274
|
+
CONFLICT: EXIT.conflict,
|
|
1275
|
+
VALIDATION_FAILED: EXIT.validation,
|
|
1276
|
+
MANIFEST_PARSE_FAILED: EXIT.validation,
|
|
1277
|
+
MANIFEST_SCHEMA_INVALID: EXIT.validation,
|
|
1278
|
+
MANIFEST_POLICY_VIOLATION: EXIT.policy,
|
|
1279
|
+
MANIFEST_UNSUPPORTED_API_VERSION: EXIT.validation,
|
|
1280
|
+
APPROVAL_REQUIRED: EXIT.policy,
|
|
1281
|
+
PLAN_STALE: EXIT.conflict,
|
|
1282
|
+
PLAN_EXPIRED: EXIT.conflict,
|
|
1283
|
+
TARGET_INCOMPATIBLE: EXIT.validation,
|
|
1284
|
+
INTERACTION_REQUIRED: EXIT.interactionRequired,
|
|
1285
|
+
PROVIDER_UNAVAILABLE: EXIT.unavailable,
|
|
1286
|
+
RATE_LIMITED: EXIT.unavailable,
|
|
1287
|
+
DELETE_CONFIRMATION_REQUIRED: EXIT.interactionRequired
|
|
1288
|
+
};
|
|
1289
|
+
var Output = class {
|
|
1290
|
+
json;
|
|
1291
|
+
constructor(options) {
|
|
1292
|
+
this.json = options.json;
|
|
1293
|
+
}
|
|
1294
|
+
/** Human-readable progress. Never written to stdout in JSON mode. */
|
|
1295
|
+
progress(message) {
|
|
1296
|
+
process.stderr.write(`${message}
|
|
1297
|
+
`);
|
|
1298
|
+
}
|
|
1299
|
+
step(ok, message) {
|
|
1300
|
+
process.stderr.write(`${ok ? "\u2713" : "\u2717"} ${message}
|
|
1301
|
+
`);
|
|
1302
|
+
}
|
|
1303
|
+
warn(message) {
|
|
1304
|
+
process.stderr.write(`! ${message}
|
|
1305
|
+
`);
|
|
1306
|
+
}
|
|
1307
|
+
/** Terminal success output. Exactly one JSON object in `--json` mode. */
|
|
1308
|
+
result(payload, human) {
|
|
1309
|
+
if (this.json) {
|
|
1310
|
+
process.stdout.write(`${JSON.stringify({ apiVersion: CLI_API_VERSION, ok: true, ...payload }, null, 2)}
|
|
1311
|
+
`);
|
|
1312
|
+
return;
|
|
1313
|
+
}
|
|
1314
|
+
human();
|
|
1315
|
+
}
|
|
1316
|
+
fail(error) {
|
|
1317
|
+
if (isTinyError(error)) {
|
|
1318
|
+
const body = {
|
|
1319
|
+
apiVersion: CLI_API_VERSION,
|
|
1320
|
+
ok: false,
|
|
1321
|
+
error: {
|
|
1322
|
+
code: error.code,
|
|
1323
|
+
message: error.message,
|
|
1324
|
+
remediation: error.remediation,
|
|
1325
|
+
retryable: error.retryable,
|
|
1326
|
+
details: error.details
|
|
1327
|
+
}
|
|
1328
|
+
};
|
|
1329
|
+
if (this.json) process.stdout.write(`${JSON.stringify(body, null, 2)}
|
|
1330
|
+
`);
|
|
1331
|
+
else {
|
|
1332
|
+
process.stderr.write(`
|
|
1333
|
+
\u2717 ${error.code}: ${error.message}
|
|
1334
|
+
`);
|
|
1335
|
+
process.stderr.write(` \u2192 ${error.remediation}
|
|
1336
|
+
`);
|
|
1337
|
+
const next = error.details.nextActions;
|
|
1338
|
+
if (Array.isArray(next)) for (const action of next) process.stderr.write(` \u2192 ${String(action)}
|
|
1339
|
+
`);
|
|
1340
|
+
}
|
|
1341
|
+
process.exit(EXIT_BY_CODE[error.code] ?? EXIT.error);
|
|
1342
|
+
}
|
|
1343
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
1344
|
+
if (this.json) process.stdout.write(`${JSON.stringify({ apiVersion: CLI_API_VERSION, ok: false, error: { code: "INTERNAL", message } }, null, 2)}
|
|
1345
|
+
`);
|
|
1346
|
+
else process.stderr.write(`
|
|
1347
|
+
\u2717 ${message}
|
|
1348
|
+
`);
|
|
1349
|
+
process.exit(EXIT.error);
|
|
1350
|
+
}
|
|
1351
|
+
};
|
|
1352
|
+
function isInteractive() {
|
|
1353
|
+
return process.stdin.isTTY === true && process.stdout.isTTY === true;
|
|
1354
|
+
}
|
|
1355
|
+
|
|
1356
|
+
// ../cli/src/main.ts
|
|
1357
|
+
function parseArgs(argv) {
|
|
1358
|
+
const positional = [];
|
|
1359
|
+
const flags = {};
|
|
1360
|
+
for (let index = 0; index < argv.length; index++) {
|
|
1361
|
+
const token = argv[index];
|
|
1362
|
+
if (token.startsWith("--")) {
|
|
1363
|
+
const [name, inline] = token.slice(2).split("=");
|
|
1364
|
+
if (inline !== void 0) flags[name] = inline;
|
|
1365
|
+
else if (argv[index + 1] && !argv[index + 1].startsWith("-")) flags[name] = argv[++index];
|
|
1366
|
+
else flags[name] = true;
|
|
1367
|
+
} else {
|
|
1368
|
+
positional.push(token);
|
|
1369
|
+
}
|
|
1370
|
+
}
|
|
1371
|
+
const [command = "help", ...rest] = positional;
|
|
1372
|
+
const grouped = /* @__PURE__ */ new Set(["access", "secrets", "capabilities", "preview", "org"]);
|
|
1373
|
+
return {
|
|
1374
|
+
command,
|
|
1375
|
+
subcommand: grouped.has(command) ? rest[0] ?? null : null,
|
|
1376
|
+
positional: grouped.has(command) ? rest.slice(1) : rest,
|
|
1377
|
+
flags
|
|
1378
|
+
};
|
|
1379
|
+
}
|
|
1380
|
+
var USAGE = `tiny \u2014 governed runtime for small internal apps
|
|
1381
|
+
|
|
1382
|
+
tiny login --api <url> [--token <token>] Store credentials for a control plane
|
|
1383
|
+
tiny logout Remove stored credentials
|
|
1384
|
+
tiny whoami Show the current identity
|
|
1385
|
+
tiny org create --slug <slug> --owner <email>
|
|
1386
|
+
Bootstrap an organization (returns an API token)
|
|
1387
|
+
|
|
1388
|
+
tiny init [name] Write a starter tiny.yaml
|
|
1389
|
+
tiny validate Parse and check tiny.yaml
|
|
1390
|
+
tiny plan [--env production] Show what a deploy would do (never mutates)
|
|
1391
|
+
tiny deploy [--env production] [--yes] Deploy and watch it finish
|
|
1392
|
+
[--no-wait] [--wait <seconds>]
|
|
1393
|
+
tiny status Show apps and their lifecycle status
|
|
1394
|
+
tiny logs [app] [--since 1h] [--limit 100] Read bounded logs
|
|
1395
|
+
tiny open [app] Print the app URL
|
|
1396
|
+
tiny rollback <deployment> Repoint traffic at a previous deployment
|
|
1397
|
+
|
|
1398
|
+
tiny access list|grant|revoke <subject> [--role user]
|
|
1399
|
+
tiny secrets set <name>|list
|
|
1400
|
+
tiny capabilities list|request <operation> --connection <name>
|
|
1401
|
+
tiny preview create [--ttl 72h]|delete <environment>
|
|
1402
|
+
|
|
1403
|
+
tiny archive [app] Snapshot and deactivate
|
|
1404
|
+
tiny restore [app] Reactivate an archived app
|
|
1405
|
+
tiny delete [app] --confirm <slug> Schedule deletion
|
|
1406
|
+
tiny doctor Check auth, manifest files, and target reachability
|
|
1407
|
+
|
|
1408
|
+
Global flags: --json --api <url> --token <token> --cwd <path> --idempotency-key <key>
|
|
1409
|
+
|
|
1410
|
+
Exit codes: 0 ok, 2 usage, 3 auth, 4 validation, 5 policy, 6 not found,
|
|
1411
|
+
7 interaction required, 8 conflict, 9 unavailable.
|
|
1412
|
+
`;
|
|
1413
|
+
async function main() {
|
|
1414
|
+
const args = parseArgs(process.argv.slice(2));
|
|
1415
|
+
const output = new Output({ json: args.flags.json === true || args.flags.json === "true" });
|
|
1416
|
+
try {
|
|
1417
|
+
await dispatch(args, output);
|
|
1418
|
+
} catch (error) {
|
|
1419
|
+
output.fail(error);
|
|
1420
|
+
}
|
|
1421
|
+
}
|
|
1422
|
+
function cwdOf(args) {
|
|
1423
|
+
return resolve(typeof args.flags.cwd === "string" ? args.flags.cwd : process.cwd());
|
|
1424
|
+
}
|
|
1425
|
+
function readManifest(cwd) {
|
|
1426
|
+
const path = join5(cwd, "tiny.yaml");
|
|
1427
|
+
if (!existsSync3(path)) {
|
|
1428
|
+
throw new TinyError("NOT_FOUND", "No tiny.yaml in this directory.", {
|
|
1429
|
+
remediation: "Run `tiny init` to create one, or pass --cwd with the app directory.",
|
|
1430
|
+
details: { expected: path }
|
|
1431
|
+
});
|
|
1432
|
+
}
|
|
1433
|
+
return { source: readFileSync4(path, "utf8"), path };
|
|
1434
|
+
}
|
|
1435
|
+
function clientFor(args) {
|
|
1436
|
+
const profile = currentProfile();
|
|
1437
|
+
const apiUrl = (typeof args.flags.api === "string" ? args.flags.api : void 0) ?? process.env.TINY_API_URL ?? profile?.apiUrl;
|
|
1438
|
+
const token = (typeof args.flags.token === "string" ? args.flags.token : void 0) ?? process.env.TINY_TOKEN ?? profile?.token;
|
|
1439
|
+
if (!apiUrl) {
|
|
1440
|
+
throw new TinyError("INTERACTION_REQUIRED", "No control plane configured.", {
|
|
1441
|
+
remediation: "Run `tiny login --api <url> --token <token>`, or pass --api.",
|
|
1442
|
+
details: { missingInput: "--api" }
|
|
1443
|
+
});
|
|
1444
|
+
}
|
|
1445
|
+
if (!token) {
|
|
1446
|
+
throw new TinyError("UNAUTHENTICATED", "No credentials for this control plane.", {
|
|
1447
|
+
remediation: "Run `tiny login --api <url> --token <token>`.",
|
|
1448
|
+
details: { missingInput: "--token" }
|
|
1449
|
+
});
|
|
1450
|
+
}
|
|
1451
|
+
return new ApiClient({ apiUrl, token });
|
|
1452
|
+
}
|
|
1453
|
+
async function promptSecret(label, missingInput) {
|
|
1454
|
+
if (!isInteractive()) {
|
|
1455
|
+
if (!process.stdin.isTTY) {
|
|
1456
|
+
const piped = readFileSync4(0, "utf8").trim();
|
|
1457
|
+
if (piped) return piped;
|
|
1458
|
+
}
|
|
1459
|
+
throw new TinyError("INTERACTION_REQUIRED", `${label} is required and stdin is not a terminal.`, {
|
|
1460
|
+
remediation: `Pipe the value on stdin, or pass ${missingInput}.`,
|
|
1461
|
+
details: { missingInput }
|
|
1462
|
+
});
|
|
1463
|
+
}
|
|
1464
|
+
const rl = createInterface({ input: process.stdin, output: process.stderr });
|
|
1465
|
+
try {
|
|
1466
|
+
return (await rl.question(`${label}: `)).trim();
|
|
1467
|
+
} finally {
|
|
1468
|
+
rl.close();
|
|
1469
|
+
}
|
|
1470
|
+
}
|
|
1471
|
+
function waitTimeoutMs(args) {
|
|
1472
|
+
const flag = args.flags.wait;
|
|
1473
|
+
if (typeof flag !== "string") return DEFAULT_WAIT_MS;
|
|
1474
|
+
const seconds = Number(flag);
|
|
1475
|
+
if (!Number.isFinite(seconds) || seconds <= 0) throw usageError("tiny deploy --wait <seconds>");
|
|
1476
|
+
return seconds * 1e3;
|
|
1477
|
+
}
|
|
1478
|
+
async function dispatch(args, output) {
|
|
1479
|
+
const cwd = cwdOf(args);
|
|
1480
|
+
switch (args.command) {
|
|
1481
|
+
case "help":
|
|
1482
|
+
case "--help":
|
|
1483
|
+
process.stderr.write(USAGE);
|
|
1484
|
+
process.exit(args.command === "help" ? EXIT.ok : EXIT.usage);
|
|
1485
|
+
return;
|
|
1486
|
+
// ------------------------------------------------------------- auth
|
|
1487
|
+
case "login": {
|
|
1488
|
+
const apiUrl = typeof args.flags.api === "string" ? args.flags.api : process.env.TINY_API_URL;
|
|
1489
|
+
if (!apiUrl) {
|
|
1490
|
+
throw new TinyError("INTERACTION_REQUIRED", "A control plane URL is required.", {
|
|
1491
|
+
remediation: "Pass --api <url>.",
|
|
1492
|
+
details: { missingInput: "--api" }
|
|
1493
|
+
});
|
|
1494
|
+
}
|
|
1495
|
+
const token = typeof args.flags.token === "string" ? args.flags.token : await promptSecret("API token", "--token");
|
|
1496
|
+
const profile = typeof args.flags.profile === "string" ? args.flags.profile : "default";
|
|
1497
|
+
setProfile(profile, { apiUrl, token });
|
|
1498
|
+
output.result({ profile, apiUrl }, () => output.step(true, `Credentials stored for ${apiUrl} (profile "${profile}").`));
|
|
1499
|
+
return;
|
|
1500
|
+
}
|
|
1501
|
+
case "logout": {
|
|
1502
|
+
clearProfile(typeof args.flags.profile === "string" ? args.flags.profile : void 0);
|
|
1503
|
+
output.result({ loggedOut: true }, () => output.step(true, "Credentials removed."));
|
|
1504
|
+
return;
|
|
1505
|
+
}
|
|
1506
|
+
case "whoami": {
|
|
1507
|
+
const profile = currentProfile();
|
|
1508
|
+
if (!profile) {
|
|
1509
|
+
throw new TinyError("UNAUTHENTICATED", "Not logged in.", { remediation: "Run `tiny login --api <url>`." });
|
|
1510
|
+
}
|
|
1511
|
+
const capabilities = await clientFor(args).get("/v1/platform/capabilities");
|
|
1512
|
+
output.result({ apiUrl: profile.apiUrl, platform: capabilities }, () => {
|
|
1513
|
+
output.step(true, `Control plane: ${profile.apiUrl}`);
|
|
1514
|
+
});
|
|
1515
|
+
return;
|
|
1516
|
+
}
|
|
1517
|
+
case "org": {
|
|
1518
|
+
if (args.subcommand !== "create") throw usageError("tiny org create --slug <slug> --owner <email>");
|
|
1519
|
+
const slug = String(args.flags.slug ?? "");
|
|
1520
|
+
const owner = String(args.flags.owner ?? "");
|
|
1521
|
+
if (!slug || !owner) throw usageError("tiny org create --slug <slug> --owner <email>");
|
|
1522
|
+
const apiUrl = (typeof args.flags.api === "string" ? args.flags.api : void 0) ?? process.env.TINY_API_URL;
|
|
1523
|
+
if (!apiUrl) throw usageError("tiny org create --api <url> --slug <slug> --owner <email>");
|
|
1524
|
+
const created = await new ApiClient({ apiUrl }).post(
|
|
1525
|
+
"/v1/organizations",
|
|
1526
|
+
{ slug, displayName: String(args.flags.name ?? slug), ownerEmail: owner }
|
|
1527
|
+
);
|
|
1528
|
+
setProfile("default", { apiUrl, token: created.sessionToken, organizationSlug: slug, email: owner });
|
|
1529
|
+
output.result(
|
|
1530
|
+
{ organizationId: created.organization.id, slug: created.organization.slug, apiToken: created.apiToken },
|
|
1531
|
+
() => {
|
|
1532
|
+
output.step(true, `Organization ${created.organization.slug} created.`);
|
|
1533
|
+
output.step(true, "Credentials stored for this shell.");
|
|
1534
|
+
output.progress(`
|
|
1535
|
+
Service account token (shown once): ${created.apiToken}`);
|
|
1536
|
+
}
|
|
1537
|
+
);
|
|
1538
|
+
return;
|
|
1539
|
+
}
|
|
1540
|
+
// --------------------------------------------------------- manifest
|
|
1541
|
+
case "init": {
|
|
1542
|
+
const name = args.positional[0] ?? basename(cwd);
|
|
1543
|
+
const owner = String(args.flags.owner ?? currentProfile()?.email ?? "you@example.com");
|
|
1544
|
+
const path = join5(cwd, "tiny.yaml");
|
|
1545
|
+
if (existsSync3(path) && args.flags.force !== true) {
|
|
1546
|
+
throw new TinyError("CONFLICT", "tiny.yaml already exists.", {
|
|
1547
|
+
remediation: "Pass --force to overwrite it.",
|
|
1548
|
+
details: { path }
|
|
1549
|
+
});
|
|
1550
|
+
}
|
|
1551
|
+
writeFileSync3(path, starterManifest(name, owner));
|
|
1552
|
+
output.result({ path, name }, () => output.step(true, `Wrote ${path}`));
|
|
1553
|
+
return;
|
|
1554
|
+
}
|
|
1555
|
+
case "validate": {
|
|
1556
|
+
const { source } = readManifest(cwd);
|
|
1557
|
+
const parsed = parseManifest(source);
|
|
1558
|
+
output.result(
|
|
1559
|
+
{ name: parsed.normalized.metadata.name, manifestSha256: parsed.sha256, warnings: parsed.warnings },
|
|
1560
|
+
() => {
|
|
1561
|
+
output.step(true, `tiny.yaml is valid (${parsed.normalized.metadata.name}).`);
|
|
1562
|
+
for (const warning of parsed.warnings) output.warn(warning);
|
|
1563
|
+
}
|
|
1564
|
+
);
|
|
1565
|
+
return;
|
|
1566
|
+
}
|
|
1567
|
+
case "doctor": {
|
|
1568
|
+
const checks = [];
|
|
1569
|
+
const profile = currentProfile();
|
|
1570
|
+
checks.push({ name: "credentials", ok: Boolean(profile), detail: profile ? `stored for ${profile.apiUrl}` : "run `tiny login`" });
|
|
1571
|
+
let manifestOk = false;
|
|
1572
|
+
let detail = "no tiny.yaml in this directory";
|
|
1573
|
+
try {
|
|
1574
|
+
const parsed = parseManifest(readManifest(cwd).source);
|
|
1575
|
+
manifestOk = true;
|
|
1576
|
+
detail = `${parsed.normalized.metadata.name} (${parsed.normalized.runtime.type}/${parsed.normalized.runtime.language})`;
|
|
1577
|
+
const entry = join5(cwd, parsed.normalized.runtime.entrypoint);
|
|
1578
|
+
checks.push({ name: "entrypoint", ok: existsSync3(entry), detail: parsed.normalized.runtime.entrypoint });
|
|
1579
|
+
if (parsed.normalized.resources.database) {
|
|
1580
|
+
const migrations = join5(cwd, parsed.normalized.resources.database.migrations);
|
|
1581
|
+
checks.push({ name: "migrations", ok: existsSync3(migrations), detail: parsed.normalized.resources.database.migrations });
|
|
1582
|
+
}
|
|
1583
|
+
} catch (error) {
|
|
1584
|
+
detail = error instanceof Error ? error.message : String(error);
|
|
1585
|
+
}
|
|
1586
|
+
checks.unshift({ name: "manifest", ok: manifestOk, detail });
|
|
1587
|
+
for (const risky of [".env", ".env.local", "credentials.json"]) {
|
|
1588
|
+
if (existsSync3(join5(cwd, risky))) {
|
|
1589
|
+
checks.push({ name: `ignored:${risky}`, ok: true, detail: "present locally, excluded from uploads" });
|
|
1590
|
+
}
|
|
1591
|
+
}
|
|
1592
|
+
if (profile) {
|
|
1593
|
+
try {
|
|
1594
|
+
await clientFor(args).get("/v1/platform/capabilities");
|
|
1595
|
+
checks.push({ name: "control plane", ok: true, detail: profile.apiUrl });
|
|
1596
|
+
} catch (error) {
|
|
1597
|
+
checks.push({ name: "control plane", ok: false, detail: error.message });
|
|
1598
|
+
}
|
|
1599
|
+
}
|
|
1600
|
+
const ok = checks.every((check) => check.ok);
|
|
1601
|
+
output.result({ checks, healthy: ok }, () => {
|
|
1602
|
+
for (const check of checks) output.step(check.ok, `${check.name}: ${check.detail}`);
|
|
1603
|
+
});
|
|
1604
|
+
if (!ok) process.exit(EXIT.validation);
|
|
1605
|
+
return;
|
|
1606
|
+
}
|
|
1607
|
+
// ------------------------------------------------------ plan/deploy
|
|
1608
|
+
case "plan": {
|
|
1609
|
+
const { source } = readManifest(cwd);
|
|
1610
|
+
const client = clientFor(args);
|
|
1611
|
+
const artifact = await client.uploadDirectory(cwd);
|
|
1612
|
+
const response = await client.post("/v1/apps:plan", {
|
|
1613
|
+
artifactUri: artifact.artifactUri,
|
|
1614
|
+
manifest: source,
|
|
1615
|
+
environment: typeof args.flags.env === "string" ? args.flags.env : void 0
|
|
1616
|
+
});
|
|
1617
|
+
output.result({ plan: response.plan, warnings: response.warnings }, () => printPlan(output, response.plan, response.warnings));
|
|
1618
|
+
return;
|
|
1619
|
+
}
|
|
1620
|
+
case "deploy": {
|
|
1621
|
+
const { source } = readManifest(cwd);
|
|
1622
|
+
const client = clientFor(args);
|
|
1623
|
+
output.progress("Planning\u2026");
|
|
1624
|
+
const artifact = await client.uploadDirectory(cwd);
|
|
1625
|
+
const planned = await client.post("/v1/apps:plan", {
|
|
1626
|
+
artifactUri: artifact.artifactUri,
|
|
1627
|
+
manifest: source,
|
|
1628
|
+
environment: typeof args.flags.env === "string" ? args.flags.env : void 0
|
|
1629
|
+
});
|
|
1630
|
+
if (args.flags.json !== true) printPlan(output, planned.plan, planned.warnings);
|
|
1631
|
+
if (planned.plan.approvals.length > 0 && args.flags.yes !== true && !isInteractive()) {
|
|
1632
|
+
throw new TinyError("APPROVAL_REQUIRED", "This deployment needs approval and no terminal is attached.", {
|
|
1633
|
+
details: { approvals: planned.plan.approvals, planId: planned.plan.planId, nextActions: [
|
|
1634
|
+
"Ask an organization admin to approve the plan.",
|
|
1635
|
+
"Re-run `tiny deploy --yes` once approved."
|
|
1636
|
+
] }
|
|
1637
|
+
});
|
|
1638
|
+
}
|
|
1639
|
+
output.progress("Deploying\u2026");
|
|
1640
|
+
const result = await client.post("/v1/apps:deploy", {
|
|
1641
|
+
artifactUri: artifact.artifactUri,
|
|
1642
|
+
manifest: source,
|
|
1643
|
+
environment: typeof args.flags.env === "string" ? args.flags.env : void 0,
|
|
1644
|
+
planId: planned.plan.planId,
|
|
1645
|
+
approve: args.flags.yes === true
|
|
1646
|
+
}, typeof args.flags["idempotency-key"] === "string" ? args.flags["idempotency-key"] : void 0);
|
|
1647
|
+
const settled = args.flags["no-wait"] === true ? null : await awaitDeployment(
|
|
1648
|
+
() => client.get(`/v1/deployments/${result.deploymentId}`),
|
|
1649
|
+
{ timeoutMs: waitTimeoutMs(args), onStatus: (status2) => output.progress(` ${status2}\u2026`) }
|
|
1650
|
+
);
|
|
1651
|
+
const status = settled?.status ?? result.status;
|
|
1652
|
+
const build = buildSummary(settled?.providerState);
|
|
1653
|
+
output.result(
|
|
1654
|
+
{
|
|
1655
|
+
appId: result.appId,
|
|
1656
|
+
deploymentId: result.deploymentId,
|
|
1657
|
+
status,
|
|
1658
|
+
url: result.url,
|
|
1659
|
+
warnings: result.warnings,
|
|
1660
|
+
...build ? { build } : {}
|
|
1661
|
+
},
|
|
1662
|
+
() => {
|
|
1663
|
+
output.progress("");
|
|
1664
|
+
const built = describeBuild(build);
|
|
1665
|
+
if (built) output.step(true, built);
|
|
1666
|
+
if (settled === null) {
|
|
1667
|
+
output.progress(args.flags["no-wait"] === true ? `\u2192 Deployment ${result.deploymentId} is ${status}. Follow it with \`tiny logs\`.` : `\u2192 Deployment ${result.deploymentId} is still running after ${Math.round(waitTimeoutMs(args) / 1e3)}s. Follow it with \`tiny logs\`.`);
|
|
1668
|
+
} else {
|
|
1669
|
+
output.step(status === "ready", `Deployment ${result.deploymentId} is ${status}`);
|
|
1670
|
+
if (settled.errorCode) output.warn(`${settled.errorCode}: ${describeFailure(settled)}`);
|
|
1671
|
+
}
|
|
1672
|
+
output.step(true, `URL: ${result.url}`);
|
|
1673
|
+
for (const warning of result.warnings) output.warn(warning);
|
|
1674
|
+
}
|
|
1675
|
+
);
|
|
1676
|
+
if (status === "failed") {
|
|
1677
|
+
throw new TinyError("PROVIDER_UNAVAILABLE", `Deployment ${result.deploymentId} failed: ${describeFailure(settled)}`, {
|
|
1678
|
+
// The deployer recorded why and what to do about it; repeating its
|
|
1679
|
+
// own remediation beats a generic pointer at the logs.
|
|
1680
|
+
remediation: failureRemediation(settled),
|
|
1681
|
+
details: {
|
|
1682
|
+
deploymentId: result.deploymentId,
|
|
1683
|
+
...settled?.errorCode ? { errorCode: settled.errorCode } : {},
|
|
1684
|
+
...settled?.errorDetail ? { errorDetail: settled.errorDetail } : {}
|
|
1685
|
+
}
|
|
1686
|
+
});
|
|
1687
|
+
}
|
|
1688
|
+
return;
|
|
1689
|
+
}
|
|
1690
|
+
case "rollback": {
|
|
1691
|
+
const deploymentId = args.positional[0];
|
|
1692
|
+
if (!deploymentId) throw usageError("tiny rollback <deployment>");
|
|
1693
|
+
const result = await clientFor(args).post(
|
|
1694
|
+
`/v1/deployments/${deploymentId}/rollback`
|
|
1695
|
+
);
|
|
1696
|
+
output.result({ deploymentId: result.deployment.id, status: result.deployment.status, schemaWarning: result.schemaWarning }, () => {
|
|
1697
|
+
output.step(true, `Traffic now served by ${result.deployment.id}`);
|
|
1698
|
+
if (result.schemaWarning) output.warn(result.schemaWarning);
|
|
1699
|
+
});
|
|
1700
|
+
return;
|
|
1701
|
+
}
|
|
1702
|
+
// --------------------------------------------------------- inspect
|
|
1703
|
+
case "status": {
|
|
1704
|
+
const client = clientFor(args);
|
|
1705
|
+
const apps = await client.get("/v1/organizations/self/apps");
|
|
1706
|
+
output.result({ apps: apps.items }, () => {
|
|
1707
|
+
if (apps.items.length === 0) {
|
|
1708
|
+
output.progress("No apps yet. Run `tiny deploy`.");
|
|
1709
|
+
return;
|
|
1710
|
+
}
|
|
1711
|
+
for (const app of apps.items) {
|
|
1712
|
+
output.progress(`${app.slug.padEnd(28)} ${app.status.padEnd(14)} risk=${app.riskTier} last-used=${app.lastAccessedAt ?? "never"}`);
|
|
1713
|
+
}
|
|
1714
|
+
});
|
|
1715
|
+
return;
|
|
1716
|
+
}
|
|
1717
|
+
case "logs": {
|
|
1718
|
+
const client = clientFor(args);
|
|
1719
|
+
const appRef = args.positional[0] ?? parseManifest(readManifest(cwd).source).normalized.metadata.name;
|
|
1720
|
+
const app = await resolveApp(client, appRef);
|
|
1721
|
+
const deployments = await client.get(`/v1/apps/${app.id}/deployments`, { limit: 1 });
|
|
1722
|
+
const deployment = deployments.items[0];
|
|
1723
|
+
if (!deployment) throw new TinyError("NOT_FOUND", `App ${appRef} has no deployments yet.`);
|
|
1724
|
+
const logs = await client.get(
|
|
1725
|
+
`/v1/deployments/${deployment.id}/logs`,
|
|
1726
|
+
{
|
|
1727
|
+
limit: Number(args.flags.limit ?? 100),
|
|
1728
|
+
since: typeof args.flags.since === "string" ? sinceToIso(args.flags.since) : void 0
|
|
1729
|
+
}
|
|
1730
|
+
);
|
|
1731
|
+
output.result({ appId: app.id, deploymentId: deployment.id, logs: logs.items }, () => {
|
|
1732
|
+
for (const event of logs.items) {
|
|
1733
|
+
process.stdout.write(`${event.timestamp} ${event.level.padEnd(5)} ${event.message}
|
|
1734
|
+
`);
|
|
1735
|
+
}
|
|
1736
|
+
});
|
|
1737
|
+
return;
|
|
1738
|
+
}
|
|
1739
|
+
case "open": {
|
|
1740
|
+
const client = clientFor(args);
|
|
1741
|
+
const appRef = args.positional[0] ?? parseManifest(readManifest(cwd).source).normalized.metadata.name;
|
|
1742
|
+
const app = await resolveApp(client, appRef);
|
|
1743
|
+
const environments = await client.get(`/v1/apps/${app.id}/environments`);
|
|
1744
|
+
const environment = environments.items.find((entry) => entry.name === (args.flags.env ?? "production")) ?? environments.items[0];
|
|
1745
|
+
if (!environment) throw new TinyError("NOT_FOUND", `App ${app.slug} has no environments yet.`);
|
|
1746
|
+
const url = `https://${environment.hostname}`;
|
|
1747
|
+
output.result({ appId: app.id, url, environment: environment.name }, () => process.stdout.write(`${url}
|
|
1748
|
+
`));
|
|
1749
|
+
return;
|
|
1750
|
+
}
|
|
1751
|
+
// ---------------------------------------------------------- access
|
|
1752
|
+
case "access": {
|
|
1753
|
+
const client = clientFor(args);
|
|
1754
|
+
const appRef = typeof args.flags.app === "string" ? args.flags.app : parseManifest(readManifest(cwd).source).normalized.metadata.name;
|
|
1755
|
+
const app = await resolveApp(client, appRef);
|
|
1756
|
+
if (args.subcommand === "list" || args.subcommand === null) {
|
|
1757
|
+
const bindings = await client.get(`/v1/apps/${app.id}/access`);
|
|
1758
|
+
output.result({ appId: app.id, bindings: bindings.items }, () => {
|
|
1759
|
+
for (const binding of bindings.items) output.progress(`${binding.subject.padEnd(36)} ${binding.role} (${binding.subjectType})`);
|
|
1760
|
+
});
|
|
1761
|
+
return;
|
|
1762
|
+
}
|
|
1763
|
+
if (args.subcommand === "grant") {
|
|
1764
|
+
const subject = args.positional[0];
|
|
1765
|
+
if (!subject) throw usageError("tiny access grant <subject> [--role user]");
|
|
1766
|
+
const binding = await client.put(
|
|
1767
|
+
`/v1/apps/${app.id}/access/${encodeURIComponent(subject)}`,
|
|
1768
|
+
{ role: typeof args.flags.role === "string" ? args.flags.role : "user" }
|
|
1769
|
+
);
|
|
1770
|
+
output.result(
|
|
1771
|
+
{ appId: app.id, subject: binding.subject, role: binding.role },
|
|
1772
|
+
() => output.step(true, `${binding.subject} granted ${binding.role}`)
|
|
1773
|
+
);
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if (args.subcommand === "revoke") {
|
|
1777
|
+
const subject = args.positional[0];
|
|
1778
|
+
if (!subject) throw usageError("tiny access revoke <subject>");
|
|
1779
|
+
await client.del(`/v1/apps/${app.id}/access/${encodeURIComponent(subject)}`);
|
|
1780
|
+
output.result({ appId: app.id, subject, revoked: true }, () => output.step(true, `${subject} revoked`));
|
|
1781
|
+
return;
|
|
1782
|
+
}
|
|
1783
|
+
throw usageError("tiny access list|grant|revoke");
|
|
1784
|
+
}
|
|
1785
|
+
// --------------------------------------------------------- secrets
|
|
1786
|
+
case "secrets": {
|
|
1787
|
+
const client = clientFor(args);
|
|
1788
|
+
if (args.subcommand === "list") {
|
|
1789
|
+
const secrets = await client.get("/v1/organizations/self/secrets");
|
|
1790
|
+
output.result({ secrets: secrets.items }, () => {
|
|
1791
|
+
for (const secret of secrets.items) output.progress(`${secret.name.padEnd(32)} ${secret.createdAt}`);
|
|
1792
|
+
});
|
|
1793
|
+
return;
|
|
1794
|
+
}
|
|
1795
|
+
if (args.subcommand === "set") {
|
|
1796
|
+
const name = args.positional[0];
|
|
1797
|
+
if (!name) throw usageError("tiny secrets set <name>");
|
|
1798
|
+
const value = await promptSecret(`Value for ${name}`, "--stdin");
|
|
1799
|
+
const result = await client.post("/v1/organizations/self/secrets", { name, value });
|
|
1800
|
+
output.result({ name, version: result.version }, () => output.step(true, `Secret ${name} stored as version ${result.version}`));
|
|
1801
|
+
return;
|
|
1802
|
+
}
|
|
1803
|
+
throw usageError("tiny secrets set|list");
|
|
1804
|
+
}
|
|
1805
|
+
// ---------------------------------------------------- capabilities
|
|
1806
|
+
case "capabilities": {
|
|
1807
|
+
const client = clientFor(args);
|
|
1808
|
+
if (args.subcommand === "list" || args.subcommand === null) {
|
|
1809
|
+
const catalog = await client.get("/v1/capabilities");
|
|
1810
|
+
output.result({ capabilities: catalog.items }, () => {
|
|
1811
|
+
for (const operation of catalog.items) output.progress(`${operation.id.padEnd(30)} ${operation.risk.padEnd(12)} ${operation.version}`);
|
|
1812
|
+
});
|
|
1813
|
+
return;
|
|
1814
|
+
}
|
|
1815
|
+
if (args.subcommand === "request") {
|
|
1816
|
+
const operation = args.positional[0];
|
|
1817
|
+
const connection = typeof args.flags.connection === "string" ? args.flags.connection : null;
|
|
1818
|
+
if (!operation || !connection) throw usageError("tiny capabilities request <operation> --connection <name>");
|
|
1819
|
+
const appRef = typeof args.flags.app === "string" ? args.flags.app : parseManifest(readManifest(cwd).source).normalized.metadata.name;
|
|
1820
|
+
const app = await resolveApp(client, appRef);
|
|
1821
|
+
const grant = await client.post(`/v1/apps/${app.id}/capability-grants`, {
|
|
1822
|
+
connection,
|
|
1823
|
+
operations: [operation]
|
|
1824
|
+
});
|
|
1825
|
+
output.result({ grantId: grant.id, status: grant.status, operation }, () => {
|
|
1826
|
+
output.step(grant.status === "approved", `Grant ${grant.id} is ${grant.status}`);
|
|
1827
|
+
if (grant.status === "pending") output.progress("An organization admin must approve it before the app can call the operation.");
|
|
1828
|
+
});
|
|
1829
|
+
return;
|
|
1830
|
+
}
|
|
1831
|
+
throw usageError("tiny capabilities list|request");
|
|
1832
|
+
}
|
|
1833
|
+
// --------------------------------------------------------- preview
|
|
1834
|
+
case "preview": {
|
|
1835
|
+
const client = clientFor(args);
|
|
1836
|
+
if (args.subcommand === "create") {
|
|
1837
|
+
const { source } = readManifest(cwd);
|
|
1838
|
+
const artifact = await client.uploadDirectory(cwd);
|
|
1839
|
+
const result = await client.post("/v1/apps:preview", {
|
|
1840
|
+
artifactUri: artifact.artifactUri,
|
|
1841
|
+
manifest: source,
|
|
1842
|
+
name: typeof args.flags.name === "string" ? args.flags.name : void 0,
|
|
1843
|
+
ttlHours: args.flags.ttl ? hoursFrom(String(args.flags.ttl)) : void 0
|
|
1844
|
+
});
|
|
1845
|
+
output.result(result, () => output.step(true, `Preview ready at ${result.url}`));
|
|
1846
|
+
return;
|
|
1847
|
+
}
|
|
1848
|
+
if (args.subcommand === "delete") {
|
|
1849
|
+
const environmentId = args.positional[0];
|
|
1850
|
+
if (!environmentId) throw usageError("tiny preview delete <environment>");
|
|
1851
|
+
await client.del(`/v1/environments/${environmentId}`);
|
|
1852
|
+
output.result({ environmentId, deleted: true }, () => output.step(true, `Preview ${environmentId} deleted`));
|
|
1853
|
+
return;
|
|
1854
|
+
}
|
|
1855
|
+
throw usageError("tiny preview create|delete");
|
|
1856
|
+
}
|
|
1857
|
+
// ------------------------------------------------------- lifecycle
|
|
1858
|
+
case "archive":
|
|
1859
|
+
case "restore":
|
|
1860
|
+
case "delete": {
|
|
1861
|
+
const client = clientFor(args);
|
|
1862
|
+
const appRef = args.positional[0] ?? parseManifest(readManifest(cwd).source).normalized.metadata.name;
|
|
1863
|
+
const app = await resolveApp(client, appRef);
|
|
1864
|
+
if (args.command === "archive") {
|
|
1865
|
+
const result = await client.post(`/v1/apps/${app.id}/archive`);
|
|
1866
|
+
output.result(
|
|
1867
|
+
{ appId: app.id, ...result },
|
|
1868
|
+
() => output.step(true, `${app.slug} archived with ${result.snapshots} snapshot(s).`)
|
|
1869
|
+
);
|
|
1870
|
+
return;
|
|
1871
|
+
}
|
|
1872
|
+
if (args.command === "restore") {
|
|
1873
|
+
const restored = await client.post(`/v1/apps/${app.id}/restore`);
|
|
1874
|
+
output.result(
|
|
1875
|
+
{ appId: app.id, status: restored.status },
|
|
1876
|
+
() => output.step(true, `${app.slug} restored. Deploy again to bring it back online.`)
|
|
1877
|
+
);
|
|
1878
|
+
return;
|
|
1879
|
+
}
|
|
1880
|
+
const confirm = typeof args.flags.confirm === "string" ? args.flags.confirm : null;
|
|
1881
|
+
if (!confirm) {
|
|
1882
|
+
throw new TinyError("DELETE_CONFIRMATION_REQUIRED", `Deleting "${app.slug}" requires confirmation.`, {
|
|
1883
|
+
remediation: `Re-run with --confirm ${app.slug}.`,
|
|
1884
|
+
details: { missingInput: "--confirm", expected: app.slug }
|
|
1885
|
+
});
|
|
1886
|
+
}
|
|
1887
|
+
const scheduled = await client.del(`/v1/apps/${app.id}`, { confirm });
|
|
1888
|
+
output.result(
|
|
1889
|
+
{ appId: app.id, deleteAfter: scheduled.deleteAfter },
|
|
1890
|
+
() => output.step(true, `${app.slug} will be deleted after ${scheduled.deleteAfter}.`)
|
|
1891
|
+
);
|
|
1892
|
+
return;
|
|
1893
|
+
}
|
|
1894
|
+
default:
|
|
1895
|
+
process.stderr.write(`Unknown command "${args.command}".
|
|
1896
|
+
|
|
1897
|
+
${USAGE}`);
|
|
1898
|
+
process.exit(EXIT.usage);
|
|
1899
|
+
}
|
|
1900
|
+
}
|
|
1901
|
+
async function resolveApp(client, reference) {
|
|
1902
|
+
if (reference.startsWith("app_")) return client.get(`/v1/apps/${reference}`);
|
|
1903
|
+
const apps = await client.get("/v1/organizations/self/apps");
|
|
1904
|
+
const app = apps.items.find((entry) => entry.slug === reference);
|
|
1905
|
+
if (!app) {
|
|
1906
|
+
throw new TinyError("NOT_FOUND", `No app "${reference}" in this organization.`, {
|
|
1907
|
+
remediation: "Run `tiny status` to list apps."
|
|
1908
|
+
});
|
|
1909
|
+
}
|
|
1910
|
+
return app;
|
|
1911
|
+
}
|
|
1912
|
+
function printPlan(output, plan, warnings) {
|
|
1913
|
+
output.progress("");
|
|
1914
|
+
for (const step of plan.steps) output.progress(` ${step.kind.padEnd(7)} ${step.resource.padEnd(34)} ${step.detail}`);
|
|
1915
|
+
if (plan.migrations.pending.length > 0) {
|
|
1916
|
+
output.progress(` migrations: ${plan.migrations.pending.join(", ")}`);
|
|
1917
|
+
for (const destructive of plan.migrations.destructive) output.warn(`${destructive} contains destructive statements.`);
|
|
1918
|
+
}
|
|
1919
|
+
for (const capability of plan.capabilities) {
|
|
1920
|
+
output.progress(` capability ${capability.operation} \u2192 ${capability.status}`);
|
|
1921
|
+
}
|
|
1922
|
+
output.progress(` risk: ${plan.riskTier}${plan.estimatedMonthlyCents === null ? "" : ` estimate: $${(plan.estimatedMonthlyCents / 100).toFixed(2)}/mo`}`);
|
|
1923
|
+
for (const warning of [...warnings, ...plan.warnings]) output.warn(warning);
|
|
1924
|
+
for (const problem of plan.incompatibilities) output.warn(`incompatible: ${problem}`);
|
|
1925
|
+
for (const approval of plan.approvals) output.warn(`approval required \u2014 ${approval.code}: ${approval.detail}`);
|
|
1926
|
+
output.progress("");
|
|
1927
|
+
}
|
|
1928
|
+
function usageError(usage) {
|
|
1929
|
+
return new TinyError("VALIDATION_FAILED", `Usage: ${usage}`, { remediation: `Run: ${usage}` });
|
|
1930
|
+
}
|
|
1931
|
+
function sinceToIso(value) {
|
|
1932
|
+
const match = /^(\d+)([smhd])$/.exec(value);
|
|
1933
|
+
if (!match) return value;
|
|
1934
|
+
const unit = { s: 1e3, m: 6e4, h: 36e5, d: 864e5 }[match[2]];
|
|
1935
|
+
return new Date(Date.now() - Number(match[1]) * unit).toISOString();
|
|
1936
|
+
}
|
|
1937
|
+
function hoursFrom(value) {
|
|
1938
|
+
const match = /^(\d+)([hd])$/.exec(value);
|
|
1939
|
+
if (!match) return Number(value);
|
|
1940
|
+
return Number(match[1]) * (match[2] === "d" ? 24 : 1);
|
|
1941
|
+
}
|
|
1942
|
+
await main();
|
|
1943
|
+
//# sourceMappingURL=main.js.map
|