turnzero 0.12.1 → 0.13.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/dist/turnzero-cloud.mjs +1149 -127
- package/package.json +6 -1
package/dist/turnzero-cloud.mjs
CHANGED
|
@@ -109,7 +109,7 @@ var UPLOAD_GRANT_FORM = new RegExp(`^${UPLOAD_GRANT_PREFIX}[A-Za-z0-9_-]{43}$`);
|
|
|
109
109
|
var commandUserAgent = (version, appended) => `${TURNZERO_CLOUD_PACKAGE}/${version}${appended ? ` ${appended}` : ""}`;
|
|
110
110
|
var COMMAND_STATUS = Object.freeze({ done: 0, failed: 1, unknown: 2, nothing_started: 3 });
|
|
111
111
|
var COMMAND_NODE_FLOOR = 24;
|
|
112
|
-
var nodeFloorSentence = (found) =>
|
|
112
|
+
var nodeFloorSentence = (version, found) => `${TURNZERO_CLOUD_PACKAGE} ${version} needs Node.js ${COMMAND_NODE_FLOOR} or later, and this machine runs Node.js ${found}, so nothing was read and nothing was started. Install Node.js ${COMMAND_NODE_FLOOR} or later, then run the line again.`;
|
|
113
113
|
var MINTED_TOKEN_VARIABLE = "TURNZERO_CLOUD_MINTED_TOKEN";
|
|
114
114
|
var ORIGIN_VARIABLE = "TURNZERO_CLOUD_ORIGIN";
|
|
115
115
|
var MINTED_TOKEN_PREFIX = TOKEN_PREFIX;
|
|
@@ -191,12 +191,15 @@ var SCAFFOLD_FIELD_NAME_FORM = /^[a-z][a-z0-9_]{0,29}$/;
|
|
|
191
191
|
var SCAFFOLD_FIELD_KINDS = Object.freeze(["text", "integer", "boolean", "timestamp"]);
|
|
192
192
|
var SCAFFOLD_FIELDS_MAX = 20;
|
|
193
193
|
var SCAFFOLD_FIELD_RESERVED = Object.freeze(["id"]);
|
|
194
|
-
var SCAFFOLD_TEMPLATES = Object.freeze(["hello-world", "database-service", "scheduled-job"]);
|
|
194
|
+
var SCAFFOLD_TEMPLATES = Object.freeze(["hello-world", "database-service", "scheduled-job", "web-app"]);
|
|
195
195
|
var SCAFFOLD_DEFAULT_TEMPLATE = "hello-world";
|
|
196
196
|
var SCAFFOLD_TEMPLATE_ENTRIES = Object.freeze({
|
|
197
|
-
"hello-world":
|
|
198
|
-
"database-service": Object.freeze({ entry: "database", writes: "database-backed service" }),
|
|
199
|
-
"scheduled-job": Object.freeze({ entry: "schedule", writes: "service that runs a scheduled job" })
|
|
197
|
+
"hello-world": Object.freeze([]),
|
|
198
|
+
"database-service": Object.freeze([Object.freeze({ entry: "database", writes: "database-backed service" })]),
|
|
199
|
+
"scheduled-job": Object.freeze([Object.freeze({ entry: "schedule", writes: "service that runs a scheduled job" })]),
|
|
200
|
+
"web-app": Object.freeze(
|
|
201
|
+
["database", "account", "ai_allowance"].map((entry) => Object.freeze({ entry, writes: "web app with sign-in, a database, and the AI allowance" }))
|
|
202
|
+
)
|
|
200
203
|
});
|
|
201
204
|
var scaffoldLineOf = (call) => commandLineOf(call.origin, SCAFFOLD_SUBCOMMAND, {
|
|
202
205
|
options: [
|
|
@@ -322,6 +325,33 @@ var settingsOf = (text) => {
|
|
|
322
325
|
return { settings: [...settings] };
|
|
323
326
|
};
|
|
324
327
|
|
|
328
|
+
// src/wire_version.ts
|
|
329
|
+
var WIRE_VERSION = /^(\d{4}-\d{2}-\d{2})\.(\d+)$/;
|
|
330
|
+
var PLANE_WIRE_KEYS = ["action_payloads", "management_api", "wire_errors"];
|
|
331
|
+
function parseWireVersion(value) {
|
|
332
|
+
if (typeof value !== "string") return null;
|
|
333
|
+
const match = WIRE_VERSION.exec(value);
|
|
334
|
+
if (!match) return null;
|
|
335
|
+
const counter = Number(match[2]);
|
|
336
|
+
if (!Number.isSafeInteger(counter)) return null;
|
|
337
|
+
return { date: match[1], counter };
|
|
338
|
+
}
|
|
339
|
+
var isWireVersion = (value) => parseWireVersion(value) !== null;
|
|
340
|
+
function compareWireVersions(a, b) {
|
|
341
|
+
const x2 = parseWireVersion(a);
|
|
342
|
+
const y = parseWireVersion(b);
|
|
343
|
+
if (!x2 || !y) throw new Error("a wire version is a date, a dot, and a counter (MAPI-02).");
|
|
344
|
+
if (x2.date !== y.date) return x2.date < y.date ? -1 : 1;
|
|
345
|
+
return x2.counter === y.counter ? 0 : x2.counter < y.counter ? -1 : 1;
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
// src/turnzero_cloud_command/plane_wire.ts
|
|
349
|
+
var PLANE_WIRE_NEEDED = Object.freeze({
|
|
350
|
+
action_payloads: "2026-10-10.11",
|
|
351
|
+
management_api: "2026-10-10.5",
|
|
352
|
+
wire_errors: "2026-10-10.6"
|
|
353
|
+
});
|
|
354
|
+
|
|
325
355
|
// src/turnzero_cloud_command/version.ts
|
|
326
356
|
import { readFileSync } from "node:fs";
|
|
327
357
|
var MANIFEST_BESIDE_BUNDLE = "../package.json";
|
|
@@ -362,10 +392,12 @@ var commandOriginOf = (text) => {
|
|
|
362
392
|
};
|
|
363
393
|
var MINTED_TOKEN_OUTSIDE_OPENING = `${DEPLOY_OPTION.origin} names an address this run does not send its minted token to.`;
|
|
364
394
|
var firstPartyOriginWayOn = (origin) => `Set ${ORIGIN_VARIABLE} to ${origin} beside ${MINTED_TOKEN_VARIABLE} and run the line again, since that variable, never the line, states where the minted token goes.`;
|
|
395
|
+
var MINTED_TOKEN_HELD_ALONE = `${MINTED_TOKEN_OUTSIDE_OPENING} Where ${ORIGIN_VARIABLE} is set, a line run by a tool connected by a minted token, or by a job, sends the token in ${MINTED_TOKEN_VARIABLE} only to the address that variable holds, so ${DEPLOY_OPTION.origin} may name that address alone. Where the platform is at another address, set ${ORIGIN_VARIABLE} to it beside the token, or unset the variable for the command's default address, and run the line again.`;
|
|
365
396
|
var mintedTokenOriginOf = (env, named, closing) => {
|
|
366
397
|
const held = heldOriginOf(env, named, closing, {
|
|
367
398
|
variable: "a line run by a tool connected by a minted token, or by a job, sends the minted token to the address the variable names",
|
|
368
|
-
outside: `${MINTED_TOKEN_OUTSIDE_OPENING} A line run by a tool connected by a minted token, or by a job, sends the token in ${MINTED_TOKEN_VARIABLE} only to ${DEFAULT_COMMAND_ORIGIN} or to the address ${ORIGIN_VARIABLE} holds in the same environment. Where the platform is at another address, set ${ORIGIN_VARIABLE} to it beside the token, and run the line again
|
|
399
|
+
outside: `${MINTED_TOKEN_OUTSIDE_OPENING} A line run by a tool connected by a minted token, or by a job, sends the token in ${MINTED_TOKEN_VARIABLE} only to ${DEFAULT_COMMAND_ORIGIN} or to the address ${ORIGIN_VARIABLE} holds in the same environment. Where the platform is at another address, set ${ORIGIN_VARIABLE} to it beside the token, and run the line again.`,
|
|
400
|
+
heldAlone: MINTED_TOKEN_HELD_ALONE
|
|
369
401
|
});
|
|
370
402
|
if (!("refusal" in held) || named === void 0 || (env[ORIGIN_VARIABLE] ?? "").trim() !== "") return held;
|
|
371
403
|
const given = originOf(named);
|
|
@@ -383,6 +415,9 @@ var heldOriginOf = (env, named, closing, words) => {
|
|
|
383
415
|
if (named === void 0) return { origin: fromEnvironment ?? DEFAULT_COMMAND_ORIGIN };
|
|
384
416
|
const given = commandOriginOf(named);
|
|
385
417
|
if ("refusal" in given) return given;
|
|
418
|
+
if (fromEnvironment !== null && words.heldAlone !== void 0) {
|
|
419
|
+
return given.origin === fromEnvironment ? given : { refusal: `${words.heldAlone} ${closing}` };
|
|
420
|
+
}
|
|
386
421
|
if (given.origin === DEFAULT_COMMAND_ORIGIN || given.origin === fromEnvironment || (words.admitted ?? []).includes(given.origin)) return given;
|
|
387
422
|
return { refusal: `${words.outside} ${closing}` };
|
|
388
423
|
};
|
|
@@ -444,6 +479,49 @@ var refusalLines = (act, status, refusal) => [
|
|
|
444
479
|
];
|
|
445
480
|
var PLATFORM_FAILURE_STATUS = 500;
|
|
446
481
|
var failureLines = (act, status, said) => said === null ? [`The ${act} was answered HTTP ${status}, a failure of the platform's or of a server between.`] : [answerLineOf(act, "failed", status, said.error), ...said.detail !== null ? [oneLine(said.detail)] : []];
|
|
482
|
+
var HEALTH_READ_PATH = "/healthz";
|
|
483
|
+
var PLANE_CHECK_BOUND_SECONDS = 10;
|
|
484
|
+
var WIRE_FILE_NOUNS = Object.freeze({
|
|
485
|
+
action_payloads: "action forms",
|
|
486
|
+
management_api: "actions",
|
|
487
|
+
wire_errors: "refusals"
|
|
488
|
+
});
|
|
489
|
+
var PLANE_OLDER_WAY_ON = "Nothing was started: run this command again once the origin serves a newer release.";
|
|
490
|
+
var PLANE_OLDER_FIRST_ACT_WAY_ON = "Nothing was started: once the origin serves a newer release, start again with the first act.";
|
|
491
|
+
var planeOlderSentence = (version, origin, key, needs, served, tokenCodeUnread) => `${TURNZERO_CLOUD_PACKAGE} ${version} needs ${WIRE_FILE_NOUNS[key]} of ${needs} or later, and ${origin} serves ${served}, an older Turn Zero Cloud. ${tokenCodeUnread === void 0 ? PLANE_OLDER_WAY_ON : `${PLANE_OLDER_FIRST_ACT_WAY_ON} ${tokenCodeUnread}`}`;
|
|
492
|
+
var servedWireOf = async (io2, origin) => {
|
|
493
|
+
const controller = new AbortController();
|
|
494
|
+
const cancel = io2.schedule(PLANE_CHECK_BOUND_SECONDS * 1e3, () => controller.abort());
|
|
495
|
+
try {
|
|
496
|
+
const response = await io2.fetch(`${origin}${HEALTH_READ_PATH}`, { method: "GET", redirect: "manual", signal: controller.signal });
|
|
497
|
+
if (response.status !== 200 || redirected(response)) {
|
|
498
|
+
await response.body?.cancel().catch(() => void 0);
|
|
499
|
+
return null;
|
|
500
|
+
}
|
|
501
|
+
const wire = planeObjectOf(await response.text())?.wire;
|
|
502
|
+
if (wire === null || typeof wire !== "object" || Array.isArray(wire)) return null;
|
|
503
|
+
return Object.fromEntries(Object.entries(wire).filter((entry) => typeof entry[1] === "string"));
|
|
504
|
+
} catch {
|
|
505
|
+
return null;
|
|
506
|
+
} finally {
|
|
507
|
+
cancel();
|
|
508
|
+
}
|
|
509
|
+
};
|
|
510
|
+
var olderMemberOf = (served, needed = PLANE_WIRE_NEEDED) => {
|
|
511
|
+
for (const key of PLANE_WIRE_KEYS) {
|
|
512
|
+
const needs = needed[key];
|
|
513
|
+
const have = Object.prototype.hasOwnProperty.call(served, key) ? served[key] : void 0;
|
|
514
|
+
if (!isWireVersion(needs) || !isWireVersion(have)) continue;
|
|
515
|
+
if (compareWireVersions(have, needs) < 0) return { key, needs, served: have };
|
|
516
|
+
}
|
|
517
|
+
return null;
|
|
518
|
+
};
|
|
519
|
+
var planeWireRefusal = async (io2, origin, tokenCodeUnread) => {
|
|
520
|
+
const served = await servedWireOf(io2, origin);
|
|
521
|
+
if (served === null) return null;
|
|
522
|
+
const older = olderMemberOf(served);
|
|
523
|
+
return older === null ? null : planeOlderSentence(commandVersion(), origin, older.key, older.needs, older.served, tokenCodeUnread);
|
|
524
|
+
};
|
|
447
525
|
|
|
448
526
|
// src/turnzero_cloud_command/credential_file.ts
|
|
449
527
|
var CREDENTIAL_FILE_VERSION = 1;
|
|
@@ -704,8 +782,8 @@ var VARIABLE_ROUTE_SENTENCES = `A tool connected by a minted token, or a job, ca
|
|
|
704
782
|
var SIGNED_IN_ROUTE_OPENER = "A tool signed in to the platform authorizes this computer instead:";
|
|
705
783
|
|
|
706
784
|
// src/turnzero_cloud_command/guard.ts
|
|
707
|
-
var GRANT_TEXT = new RegExp(`${UPLOAD_GRANT_PREFIX}[A-Za-z0-9_-]*`, "g");
|
|
708
|
-
var TOKEN_TEXT = new RegExp(`${MINTED_TOKEN_PREFIX}[A-Za-z0-9_-]*`, "g");
|
|
785
|
+
var GRANT_TEXT = new RegExp(`${UPLOAD_GRANT_PREFIX}[A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*`, "g");
|
|
786
|
+
var TOKEN_TEXT = new RegExp(`${MINTED_TOKEN_PREFIX}[A-Za-z0-9_-]*(?:\\.[A-Za-z0-9_-]+)*`, "g");
|
|
709
787
|
var REDACTED_GRANT = "<grant>";
|
|
710
788
|
var REDACTED_VALUE = "<value>";
|
|
711
789
|
var REDACTED_TOKEN_CODE = "<token code>";
|
|
@@ -756,8 +834,10 @@ var guarded = (io2) => {
|
|
|
756
834
|
// src/turnzero_cloud_command/authorize.ts
|
|
757
835
|
import { randomBytes as randomBytes2 } from "node:crypto";
|
|
758
836
|
import { hostname } from "node:os";
|
|
759
|
-
var
|
|
760
|
-
var
|
|
837
|
+
var TOKEN_CODE_UNREAD_FIRST = "A token code the line carried was not read or not presented: it is live until it expires, and it mints nothing without this computer's verifier.";
|
|
838
|
+
var TOKEN_CODE_RUN_AGAIN = "Run the line again once the cause above is put right.";
|
|
839
|
+
var TOKEN_CODE_UNREAD = `${TOKEN_CODE_UNREAD_FIRST} ${TOKEN_CODE_RUN_AGAIN}`;
|
|
840
|
+
var tokenCodeFloorSentence = (version, found) => `${TURNZERO_CLOUD_PACKAGE} ${version} needs Node.js ${COMMAND_NODE_FLOOR} or later, and this machine runs Node.js ${found}, so nothing was read and nothing was started. A token code the line carried was not read: it is live until it expires, and it mints nothing without this computer's verifier. Install Node.js ${COMMAND_NODE_FLOOR} or later, then run the line again.`;
|
|
761
841
|
var NOTHING_SENT = "Nothing was sent and nothing was started.";
|
|
762
842
|
var carriesTokenCode = (argv2) => argv2[0] === AUTHORIZE_SUBCOMMAND || argv2[0] === TOKEN_SUBCOMMAND && argv2[1] === TOKEN_CLAIM_ACT;
|
|
763
843
|
var LABEL_FALLBACK = "authorized computer";
|
|
@@ -788,8 +868,12 @@ var mintCallMembers = (kind, challenge, label) => kind === "authorized computer"
|
|
|
788
868
|
] : [[CODE_CHALLENGE_MEMBER, challenge]];
|
|
789
869
|
var firstActWayOn = (kind) => kind === "authorized computer" ? `Where the platform refuses ${CODE_CHALLENGE_MEMBER}, it does not yet serve this authorization: deploy by the line your tool's deploy call answers until the platform's next release.` : `Where the platform refuses ${CODE_CHALLENGE_MEMBER}, it does not yet serve this mint: until the platform's next release, ${MINT_TOKEN_ACTION} without it answers the token's value as it does today.`;
|
|
790
870
|
var firstActNamed = (kind, origin = DEFAULT_COMMAND_ORIGIN) => `${kind === "authorized computer" ? AUTHORIZE_SUBCOMMAND : `${TOKEN_SUBCOMMAND} ${TOKEN_REQUEST_ACT}`}${originWordsOf(origin)}`;
|
|
871
|
+
var noConnectionLine = (origin) => `Where your tool holds no connection signed in to ${origin}, ${MINT_TOKEN_ACTION} is not among its tools: a person connects the tool and signs in, in a browser, before the call.`;
|
|
791
872
|
var firstActLines = (origin, kind, pending, label) => [
|
|
792
873
|
kind === "authorized computer" ? `To authorize this computer at ${origin}, call ${MINT_TOKEN_ACTION} from your tool, through the connection you signed in with, naming the members below and no others, then run the line it answers once, as given, on this computer.` : `To mint a token whose value never passes through the chat, call ${MINT_TOKEN_ACTION} at ${origin} from your tool, through the connection you signed in with, naming the member below beside the scope_kind, application, grants, label, and expires_in_days the task needs.`,
|
|
874
|
+
// The case this command cannot see (#4273): a tool with no signed-in
|
|
875
|
+
// connection holds no mint_token, and only a person's sign-in mends it.
|
|
876
|
+
noConnectionLine(origin),
|
|
793
877
|
`${CHALLENGE_LINE_HEAD}${pending.challenge}`,
|
|
794
878
|
CALL_LINE_HEAD,
|
|
795
879
|
...mintCallMembers(kind, pending.challenge, label).map(([name, value]) => ` ${name}: ${JSON.stringify(value)}`),
|
|
@@ -941,6 +1025,12 @@ async function completeAct(io2, origin, kind, code, userAgent, say, deliver = ()
|
|
|
941
1025
|
return COMMAND_STATUS.nothing_started;
|
|
942
1026
|
}
|
|
943
1027
|
if (stage.ended) return COMMAND_STATUS.nothing_started;
|
|
1028
|
+
const older = await planeWireRefusal(io2, origin, TOKEN_CODE_UNREAD_FIRST);
|
|
1029
|
+
if (stage.ended) return COMMAND_STATUS.nothing_started;
|
|
1030
|
+
if (older !== null) {
|
|
1031
|
+
say(older);
|
|
1032
|
+
return COMMAND_STATUS.nothing_started;
|
|
1033
|
+
}
|
|
944
1034
|
stage.now = "sent";
|
|
945
1035
|
const answered = await postAction(io2, origin, MINT_TOKEN_ACTION, code, userAgent, exchangeBodyOf(pending.verifier));
|
|
946
1036
|
if (stage.ended) {
|
|
@@ -1090,7 +1180,7 @@ var AUTHORIZE_ROW = Object.freeze({
|
|
|
1090
1180
|
done: "the challenge printed, or this computer authorized",
|
|
1091
1181
|
failed: "a token was minted and not kept, or the token this computer held before was not revoked: the printed lines name revoke_token",
|
|
1092
1182
|
unknown: "the exchange drew no answer, or an answer that is not a refusal: list_tokens shows whether a token was minted",
|
|
1093
|
-
nothing_started: "nothing was minted: an option, the credential file, or the token code was refused, no request was pending, or the exchange was refused"
|
|
1183
|
+
nothing_started: "nothing was minted: an option, the credential file, or the token code was refused, no request was pending, the platform was too old, or the exchange was refused"
|
|
1094
1184
|
},
|
|
1095
1185
|
variables: [[ORIGIN_VARIABLE, AUTHORIZATION_ORIGIN_VARIABLE_LINE]],
|
|
1096
1186
|
note: [
|
|
@@ -1549,6 +1639,11 @@ async function exportDownload(call, io2) {
|
|
|
1549
1639
|
}
|
|
1550
1640
|
index = earlier;
|
|
1551
1641
|
}
|
|
1642
|
+
const older = await planeWireRefusal(io2, options.origin);
|
|
1643
|
+
if (older !== null) {
|
|
1644
|
+
io2.print(older);
|
|
1645
|
+
return COMMAND_STATUS.nothing_started;
|
|
1646
|
+
}
|
|
1552
1647
|
const headers = { authorization: `Bearer ${grant.credential}`, "user-agent": agent.userAgent, "accept-encoding": "identity" };
|
|
1553
1648
|
const exportArea = exportAreaName(options.application);
|
|
1554
1649
|
let pause = null;
|
|
@@ -1819,7 +1914,7 @@ var EXPORT_DOWNLOAD_ROW = Object.freeze({
|
|
|
1819
1914
|
done: "every file the manifest names on disk, moved ones reported",
|
|
1820
1915
|
failed: "a file failed, cut short or refused: the closing lines name the line run again, or a fresh export where a file was answered as an absent name",
|
|
1821
1916
|
unknown: `the run stopped before the end: a fresh line from ${DOWNLOAD_GRANT_ACTION} resumes it`,
|
|
1822
|
-
nothing_started: "an option, the grant, the folder, or the manifest was refused, and nothing was written"
|
|
1917
|
+
nothing_started: "an option, the grant, the folder, or the manifest was refused, or the platform was too old, and nothing was written"
|
|
1823
1918
|
},
|
|
1824
1919
|
note: [`Run the ${EXPORT_SUBCOMMAND} download line a ${DOWNLOAD_GRANT_ACTION} call answers as given, before its grant expires, from a folder that is not an application's, or with ${EXPORT_OPTION.path} naming one outside it. It writes into a new or empty folder, or one an earlier run of the same export wrote, whose files on disk it skips.`],
|
|
1825
1920
|
afterOwnError: EXPORT_AFTER_OWN_ERROR,
|
|
@@ -2644,7 +2739,7 @@ var DEPLOY_OPTIONS = Object.freeze([
|
|
|
2644
2739
|
name: DEPLOY_OPTION.origin,
|
|
2645
2740
|
value: "<origin>",
|
|
2646
2741
|
required: false,
|
|
2647
|
-
description: `The platform's address. Where absent, the address ${ORIGIN_VARIABLE} holds, or ${DEFAULT_COMMAND_ORIGIN} where it is unset. Under ${MINTED_TOKEN_VARIABLE} the token is sent only to ${
|
|
2742
|
+
description: `The platform's address. Where absent, the address ${ORIGIN_VARIABLE} holds, or ${DEFAULT_COMMAND_ORIGIN} where it is unset. Under ${MINTED_TOKEN_VARIABLE} the token is sent only to the address ${ORIGIN_VARIABLE} holds, or to ${DEFAULT_COMMAND_ORIGIN} where it is unset. Otherwise it names the address whose token the command reads from this computer's credential file, and that token is sent to that address alone.`
|
|
2648
2743
|
},
|
|
2649
2744
|
{
|
|
2650
2745
|
name: DEPLOY_OPTION.path,
|
|
@@ -3139,6 +3234,11 @@ async function deploy(named, io2, flags = /* @__PURE__ */ new Set()) {
|
|
|
3139
3234
|
io2.print(zipped.line);
|
|
3140
3235
|
for (const line of noteLinesOf(zipped.notes, true, zipped.files)) io2.print(line);
|
|
3141
3236
|
const commit = zipped.source === "folder" ? commitOfZippedFolder(options.path) : null;
|
|
3237
|
+
const older = await planeWireRefusal(io2, origin);
|
|
3238
|
+
if (older !== null) {
|
|
3239
|
+
io2.print(older);
|
|
3240
|
+
return PROGRESS_EXIT_STATUS.nothing_started;
|
|
3241
|
+
}
|
|
3142
3242
|
const prepared = await prepareDeploy(io2, { ...options, origin }, zipped.sha256, credential.credential, agent.userAgent, refused);
|
|
3143
3243
|
if ("status" in prepared) return prepared.status;
|
|
3144
3244
|
const unserved = healthPathRefusalOf(prepared, zipped.zip, zipped.places);
|
|
@@ -3291,7 +3391,7 @@ var DEPLOY_ROW = Object.freeze({
|
|
|
3291
3391
|
done: `the version deployed, or the deploy started where ${DEPLOY_NO_WAIT_VARIABLE} is set`,
|
|
3292
3392
|
failed: "the deploy failed",
|
|
3293
3393
|
unknown: "the wait stopped, or the start drew no answer: the deploy may go on, and read_status reads it",
|
|
3294
|
-
nothing_started: "an option, the credential, or the folder was refused, no credential was read, the
|
|
3394
|
+
nothing_started: "an option, the credential, or the folder was refused, no credential was read, the platform was too old, no usable upload was answered, no zipped file names the health path, the upload or start was refused, or the upload drew no answer"
|
|
3295
3395
|
},
|
|
3296
3396
|
variables: [[DEPLOY_NO_WAIT_VARIABLE, "any value returns the command once the deploy has started, without waiting for it."]],
|
|
3297
3397
|
note: [
|
|
@@ -3299,7 +3399,7 @@ var DEPLOY_ROW = Object.freeze({
|
|
|
3299
3399
|
`On a computer with no token for that address, or one that has expired, it sends nothing, ends 3, and prints two routes, in this order. A tool connected by a minted token, or a job with no tool, such as a CI job's, cannot use ${MINT_TOKEN_ACTION}: it sets ${MINTED_TOKEN_VARIABLE} from where its token is kept, such as the job's secret store, never by writing the value in a line, and ${ORIGIN_VARIABLE} to the address that minted the token where that is not ${DEFAULT_COMMAND_ORIGIN}, then runs the deploy line again.`,
|
|
3300
3400
|
`A tool signed in to the platform makes the ${MINT_TOKEN_ACTION} call the run prints with its challenge, runs the line that call answers, then runs the deploy line again. The credential file is ${CREDENTIAL_FILE_WORDS}.`,
|
|
3301
3401
|
`A tool sets ${MINTED_TOKEN_VARIABLE} for the deploy or check line alone and unsets it once that line ends, since the command cannot unset a variable of the shell that started it. A job's token is minted for the application, with an expiry. The variable's token wins over the credential file: the command makes the preparing call under that token, and sends the token in no other request.`,
|
|
3302
|
-
`Under the variable, the command sends its token only to
|
|
3402
|
+
`Under the variable, the command sends its token only to the address ${ORIGIN_VARIABLE} holds, or to ${DEFAULT_COMMAND_ORIGIN} where that variable is unset: under the token, ${DEPLOY_OPTION.origin} may name that address alone, and where the line names none the command addresses it. ${DEPLOY_OPTION.env} names the environment where a line names one, ${COMMAND_ENVIRONMENTS.join(" or ")}; where none is named, the platform chooses, and the command deploys to the environment the platform answers.`,
|
|
3303
3403
|
`A progress read answered 502, 503, or 504, which is often temporary, is made again, at most three more times, and an upload answered 503 credential_lookup_busy, which wrote nothing, is made again after the wait it names, at most twice.`,
|
|
3304
3404
|
`After its zip line, ${DEPLOY_SUBCOMMAND} prints the folder's top-level entries the zip holds on one line, at most ten and then the count of the rest, and ${HASH_SUBCOMMAND} ${HASH_OPTION.list} names every file; no such line prints for a .zip file.`,
|
|
3305
3405
|
`Then it prints the paths the zip left out, a warning for a .zip file at the folder's root, a warning for a file that looks like a key or a credential, and a warning for a health path that no file names, package files and documentation set aside, each on a line of its own and only where there is something to name. The health warning refuses nothing.`,
|
|
@@ -3349,7 +3449,7 @@ var CHECK_OPTIONS = Object.freeze([
|
|
|
3349
3449
|
name: CHECK_OPTION.origin,
|
|
3350
3450
|
value: "<origin>",
|
|
3351
3451
|
required: false,
|
|
3352
|
-
description: `The platform's address. Where absent, the address ${ORIGIN_VARIABLE} holds, or ${DEFAULT_COMMAND_ORIGIN} where it is unset. Under ${MINTED_TOKEN_VARIABLE} the token is sent only to ${
|
|
3452
|
+
description: `The platform's address. Where absent, the address ${ORIGIN_VARIABLE} holds, or ${DEFAULT_COMMAND_ORIGIN} where it is unset. Under ${MINTED_TOKEN_VARIABLE} the token is sent only to the address ${ORIGIN_VARIABLE} holds, or to ${DEFAULT_COMMAND_ORIGIN} where it is unset. Otherwise it names the address whose token the command reads from this computer's credential file, and that token is sent to that address alone.`
|
|
3353
3453
|
},
|
|
3354
3454
|
{
|
|
3355
3455
|
name: CHECK_OPTION.path,
|
|
@@ -3505,6 +3605,11 @@ async function check(named, io2) {
|
|
|
3505
3605
|
return COMMAND_STATUS.nothing_started;
|
|
3506
3606
|
}
|
|
3507
3607
|
io2.print(zipped.line);
|
|
3608
|
+
const older = await planeWireRefusal(io2, origin);
|
|
3609
|
+
if (older !== null) {
|
|
3610
|
+
io2.print(older);
|
|
3611
|
+
return COMMAND_STATUS.nothing_started;
|
|
3612
|
+
}
|
|
3508
3613
|
const answer = await callCheck(io2, { ...options, origin }, credential.credential, agent.userAgent, refused);
|
|
3509
3614
|
if ("status" in answer) return answer.status;
|
|
3510
3615
|
for (const line of noteLinesOf(zipped.notes, !answer.firstDeploy, zipped.files)) io2.print(line);
|
|
@@ -3526,7 +3631,7 @@ var CHECK_ROW = Object.freeze({
|
|
|
3526
3631
|
statuses: {
|
|
3527
3632
|
done: "none of the checks would refuse a deploy now",
|
|
3528
3633
|
failed: "a deploy would be refused now, the refused: lines naming each refusal",
|
|
3529
|
-
nothing_started: "nothing was checked: an option, the credential, or the folder was refused, no credential was read and the two routes to one were printed, or the check call was refused, drew no answer, was redirected, or
|
|
3634
|
+
nothing_started: "nothing was checked: an option, the credential, or the folder was refused, no credential was read and the two routes to one were printed, the platform was too old, or the check call was refused, drew no answer, was redirected, or failed"
|
|
3530
3635
|
},
|
|
3531
3636
|
note: [
|
|
3532
3637
|
`A check is one line, which carries no credential: run the line your tool's ${CHECK_ACTION} call answers, or ${CHECK_SUBCOMMAND} ${CHECK_OPTION.application} <application id>, from the application's folder, before a first deploy and after each fix. It reads its credential and the platform's address as ${DEPLOY_SUBCOMMAND} does, builds the zip ${DEPLOY_SUBCOMMAND} builds, and sends the platform the application alone, no byte of the zip and no hash. It uploads nothing and starts nothing.`,
|
|
@@ -3570,7 +3675,7 @@ async function hash(named, io2, flags = /* @__PURE__ */ new Set()) {
|
|
|
3570
3675
|
}
|
|
3571
3676
|
var HASH_ROW = Object.freeze({
|
|
3572
3677
|
name: HASH_SUBCOMMAND,
|
|
3573
|
-
summary: `Prints ${HASH_LINE_HEAD}<hash> on
|
|
3678
|
+
summary: `Prints ${HASH_LINE_HEAD}<hash> on the line after the version line: the SHA-256 of the zip deploy uploads from the same folder, or of a .zip file's bytes. It reads what a deploy from the folder would upload, and no deploy needs it first. A further line follows only where there is something to name: the paths the zip left out, a .zip file at the folder's root, a file that looks like a key or a credential, or a health path of manifest.json that no file names, package files and documentation set aside. With ${HASH_OPTION.list}, one line follows for each file the zip holds. It reads no grant and sends nothing, so a ${HASH_OPTION.origin} the line carries is checked and set aside.`,
|
|
3574
3679
|
options: HASH_OPTIONS,
|
|
3575
3680
|
credential: null,
|
|
3576
3681
|
statuses: { done: "the hash printed", nothing_started: "no hash was printed" },
|
|
@@ -3699,7 +3804,9 @@ var SECRET_OPTIONS = Object.freeze([
|
|
|
3699
3804
|
name: SECRET_OPTION.valueVariable,
|
|
3700
3805
|
value: "<variable name>",
|
|
3701
3806
|
required: false,
|
|
3702
|
-
|
|
3807
|
+
// The refusal of the command's own credential variables is value.ts's
|
|
3808
|
+
// valueVariableFault (PLD-L0-97), named here from the same list.
|
|
3809
|
+
description: `The name of an environment variable that holds the value, for a run where a machine sets it, such as a hosted run's secrets store. The line names the variable, never its value, and an unset or empty variable is refused. So is a name of the command's own credential variables, ${COMMAND_CREDENTIAL_VARIABLES.join(" or ")}, in any case.`
|
|
3703
3810
|
},
|
|
3704
3811
|
{
|
|
3705
3812
|
name: SECRET_OPTION.valuePrompt,
|
|
@@ -3834,6 +3941,11 @@ async function secret(call, io2) {
|
|
|
3834
3941
|
}
|
|
3835
3942
|
io2.hide(read2.value, REDACTED_VALUE);
|
|
3836
3943
|
if (options.valueFile !== null && valueFileWouldDeploy(io2, io2.cwd, options.valueFile)) io2.print(VALUE_FILE_WARNING);
|
|
3944
|
+
const older = await planeWireRefusal(io2, sent.origin);
|
|
3945
|
+
if (older !== null) {
|
|
3946
|
+
io2.print(older);
|
|
3947
|
+
return COMMAND_STATUS.nothing_started;
|
|
3948
|
+
}
|
|
3837
3949
|
if (credential.source === "credential_file") io2.print(underTokenLine(credential.origin));
|
|
3838
3950
|
const unknown = secretUnknownSentence(options.act);
|
|
3839
3951
|
let answered;
|
|
@@ -3990,6 +4102,11 @@ async function put(call, io2) {
|
|
|
3990
4102
|
io2.print(`The file ${PUT_OPTION.path} names could not be read (${errorCodeOf(error)}). ${PUT_NOTHING_SENT}`);
|
|
3991
4103
|
return COMMAND_STATUS.nothing_started;
|
|
3992
4104
|
}
|
|
4105
|
+
const older = await planeWireRefusal(io2, options.origin);
|
|
4106
|
+
if (older !== null) {
|
|
4107
|
+
io2.print(older);
|
|
4108
|
+
return COMMAND_STATUS.nothing_started;
|
|
4109
|
+
}
|
|
3993
4110
|
let answered;
|
|
3994
4111
|
try {
|
|
3995
4112
|
answered = await writeAgain(
|
|
@@ -4517,13 +4634,16 @@ var withDependency = (text, parsed, name, spec) => {
|
|
|
4517
4634
|
`;
|
|
4518
4635
|
};
|
|
4519
4636
|
var MAKE_PACKAGE_FILE = "Make the application's package.json first, in app/ or at the project's root, then run this line again.";
|
|
4520
|
-
var templateTakingOf = (name) =>
|
|
4637
|
+
var templateTakingOf = (name) => {
|
|
4638
|
+
const taking = SCAFFOLD_TEMPLATES.filter((template) => SCAFFOLD_TEMPLATE_ENTRIES[template].some((row) => row.entry === name));
|
|
4639
|
+
return taking.find((template) => SCAFFOLD_TEMPLATE_ENTRIES[template].length === 1) ?? taking[0] ?? null;
|
|
4640
|
+
};
|
|
4521
4641
|
var OTHER_WAY_ON = Object.freeze({ clause: MAKE_PACKAGE_FILE, after: null });
|
|
4522
4642
|
var MAKE_OWN_PACKAGE_FILE = "For an application you already have, make its package.json first, then run this line again.";
|
|
4523
4643
|
var noPackageWayOnOf = (io2, options, name) => {
|
|
4524
4644
|
const template = templateTakingOf(name);
|
|
4525
|
-
const taking = template === null ?
|
|
4526
|
-
if (template === null || taking ===
|
|
4645
|
+
const taking = template === null ? void 0 : SCAFFOLD_TEMPLATE_ENTRIES[template].find((row) => row.entry === name);
|
|
4646
|
+
if (template === null || taking === void 0) return OTHER_WAY_ON;
|
|
4527
4647
|
const app = join7(options.root, "app");
|
|
4528
4648
|
let empty;
|
|
4529
4649
|
try {
|
|
@@ -4726,7 +4846,7 @@ var committedFilesOf = (copied, written) => {
|
|
|
4726
4846
|
var COPY_PACKAGES_CLAUSE = "copy the packages line into the application manifest's packages member at each submission; ";
|
|
4727
4847
|
var installFailedSentence = (folder) => `npm install in ${folder} did not finish: the entry, its row, and the copy are written. Run npm install there again; running this line again takes the entry again whole.`;
|
|
4728
4848
|
var LIBRARY_AFTER_OWN_ERROR = "the entry's folder, its manifest row, or the application's copy may stand in part, and running the line again replaces each whole";
|
|
4729
|
-
async function readTake(io2, options, userAgent, planOf) {
|
|
4849
|
+
async function readTake(io2, options, userAgent, planOf, planeChecked = false) {
|
|
4730
4850
|
const refuse = (...lines) => ({ lines });
|
|
4731
4851
|
const found = libraryFolderOf(io2, options.root);
|
|
4732
4852
|
if ("refusal" in found) return refuse(found.refusal);
|
|
@@ -4742,6 +4862,10 @@ async function readTake(io2, options, userAgent, planOf) {
|
|
|
4742
4862
|
const manifest = found.fresh ? { rows: null, stood: null } : manifestRowsOf(io2, library);
|
|
4743
4863
|
if ("refusal" in manifest) return refuse(manifest.refusal);
|
|
4744
4864
|
if (manifest.rows === null && !found.fresh && entryFoldersIn(io2, library).length > 0) return refuse(sourceSentence(`holds entries and no ${MANIFEST_FILE2}`));
|
|
4865
|
+
if (!planeChecked) {
|
|
4866
|
+
const older = await planeWireRefusal(io2, options.origin);
|
|
4867
|
+
if (older !== null) return refuse(older);
|
|
4868
|
+
}
|
|
4745
4869
|
const list = await readOf(io2, options.origin, userAgent, { name: options.entry }, LIST_READ);
|
|
4746
4870
|
if ("lines" in list) return refuse(...list.lines);
|
|
4747
4871
|
const entry = listedEntryOf(list.answer, options.entry);
|
|
@@ -5005,7 +5129,7 @@ var PROVISION_OPTIONS = Object.freeze([
|
|
|
5005
5129
|
name: PROVISION_OPTION.origin,
|
|
5006
5130
|
value: "<origin>",
|
|
5007
5131
|
required: false,
|
|
5008
|
-
description: `The platform's address, which is also written as ${LOCAL_RUN_SETTING.api} and ${LOCAL_RUN_SETTING.gatewayUrl}. Where absent, ${DEFAULT_COMMAND_ORIGIN}.`
|
|
5132
|
+
description: `The platform's address, which is also written as ${LOCAL_RUN_SETTING.api} and ${LOCAL_RUN_SETTING.gatewayUrl}. Where absent, ${DEFAULT_COMMAND_ORIGIN}; with no grant piped, the address ${ORIGIN_VARIABLE} holds where it is set. Under ${MINTED_TOKEN_VARIABLE} the token is sent only to the address ${ORIGIN_VARIABLE} holds, or to ${DEFAULT_COMMAND_ORIGIN} where it is unset. Otherwise, with no grant piped, it names the address whose token the command reads from this computer's credential file, and that token is sent to that address alone.`
|
|
5009
5133
|
}
|
|
5010
5134
|
]);
|
|
5011
5135
|
var PROVISION_CREDENTIAL = Object.freeze({
|
|
@@ -5164,6 +5288,11 @@ async function provision(call, io2) {
|
|
|
5164
5288
|
dropWritingFile(io2, options.envFile);
|
|
5165
5289
|
});
|
|
5166
5290
|
try {
|
|
5291
|
+
const older = await planeWireRefusal(io2, sent.origin);
|
|
5292
|
+
if (older !== null) {
|
|
5293
|
+
io2.print(older);
|
|
5294
|
+
return COMMAND_STATUS.nothing_started;
|
|
5295
|
+
}
|
|
5167
5296
|
return await remintAndWrite(io2, sent, agent.userAgent, read2, standing.text, reminted);
|
|
5168
5297
|
} finally {
|
|
5169
5298
|
release();
|
|
@@ -5858,6 +5987,684 @@ var SCAFFOLD_TEMPLATE_FILES = Object.freeze({
|
|
|
5858
5987
|
"});",
|
|
5859
5988
|
""
|
|
5860
5989
|
].join("\n")]
|
|
5990
|
+
]),
|
|
5991
|
+
"web-app": Object.freeze([
|
|
5992
|
+
[".gitignore", [
|
|
5993
|
+
"node_modules/",
|
|
5994
|
+
".env",
|
|
5995
|
+
".env.*",
|
|
5996
|
+
""
|
|
5997
|
+
].join("\n")],
|
|
5998
|
+
["AGENTS.md", [
|
|
5999
|
+
"# This service runs on Turn Zero Cloud",
|
|
6000
|
+
"",
|
|
6001
|
+
"The service in this folder is hosted on Turn Zero Cloud. It started from the platform's `web-app` template: a Node.js service with a web page, sign-in for its users, each user's own rows in the platform's database, and an AI model under the plan's allowance.",
|
|
6002
|
+
"",
|
|
6003
|
+
"## Where you build",
|
|
6004
|
+
"",
|
|
6005
|
+
"- The page: `client/`, plain HTML, CSS, and script, which the platform serves at the application's address.",
|
|
6006
|
+
"- The routes: `src/server.mjs`. Each route under `/api/` reads the signed-in user, and keeps and lists that user's own rows.",
|
|
6007
|
+
"- The AI model: `src/ai.mjs`, which calls it under the plan's allowance, with no key of your own.",
|
|
6008
|
+
"- The tables: `migrations/`, one file for each change, applied in order at each start.",
|
|
6009
|
+
"",
|
|
6010
|
+
"## Where the rules are",
|
|
6011
|
+
"",
|
|
6012
|
+
"Turn Zero Cloud serves its rules to a coding tool through its MCP server, `turnzero-cloud`, and they change with the platform, so none is repeated here.",
|
|
6013
|
+
"",
|
|
6014
|
+
`- Where the server is not connected, connect it first. The platform's page "Connect your tool" says how.`,
|
|
6015
|
+
"- Before you change or deploy this service, read the server's `deploy` skill, and before you change its sign-in or its AI call, the server's `add-sign-in` or `use-ai-allowance` skill.",
|
|
6016
|
+
"",
|
|
6017
|
+
"## What you run here",
|
|
6018
|
+
"",
|
|
6019
|
+
"- The tests: `npm test`, in this folder.",
|
|
6020
|
+
"- A local run and a deploy: the `deploy` skill gives each step and the line to run, in this order: `submit_manifest`, the local run, `deploy`.",
|
|
6021
|
+
"- Sign-in is on the deployed application's own address, also during a local run, since the page a local run serves has no sign-in routes. A local run's API routes take a session as a Bearer.",
|
|
6022
|
+
""
|
|
6023
|
+
].join("\n")],
|
|
6024
|
+
["client/app.js", [
|
|
6025
|
+
"// client/app.js: the page's script. It calls the backend on the same",
|
|
6026
|
+
"// address, so the browser sends the sign-in cookie with each request.",
|
|
6027
|
+
"",
|
|
6028
|
+
"// The rows' name, and the route that adds and lists the signed-in user's rows.",
|
|
6029
|
+
"const PLURAL = 'notes';",
|
|
6030
|
+
"const ROUTE = '/api/notes';",
|
|
6031
|
+
"",
|
|
6032
|
+
"// The fields of one row, each with its kind: the form's inputs and the list's columns.",
|
|
6033
|
+
"const FIELDS = [{ name: 'title', kind: 'text' }];",
|
|
6034
|
+
"",
|
|
6035
|
+
"// The input each kind of field takes.",
|
|
6036
|
+
"const INPUT_TYPES = { text: 'text', integer: 'number', boolean: 'checkbox', timestamp: 'datetime-local' };",
|
|
6037
|
+
"",
|
|
6038
|
+
"// Why the AI model gave no answer, by the error the route answers with 503.",
|
|
6039
|
+
"const UNANSWERED = {",
|
|
6040
|
+
` allowance_drawn: "This month's AI allowance is drawn, so the model gives no answer until it resets.",`,
|
|
6041
|
+
" allowance_unavailable: 'The platform holds no key for the AI allowance yet, so the model gives no answer now.',",
|
|
6042
|
+
" upstream_quota: 'The AI model is out of capacity now: ask again in a while.',",
|
|
6043
|
+
"};",
|
|
6044
|
+
"",
|
|
6045
|
+
"const byId = (id) => document.getElementById(id);",
|
|
6046
|
+
"",
|
|
6047
|
+
"// A field's value as the route takes it: a string, a number, true or false, or an ISO time.",
|
|
6048
|
+
"function valueOf(field, input) {",
|
|
6049
|
+
" if (field.kind === 'boolean') return input.checked;",
|
|
6050
|
+
" if (input.value === '') return field.kind === 'text' ? '' : null;",
|
|
6051
|
+
" if (field.kind === 'integer') return Number(input.value);",
|
|
6052
|
+
" if (field.kind === 'timestamp') return new Date(input.value).toISOString();",
|
|
6053
|
+
" return input.value;",
|
|
6054
|
+
"}",
|
|
6055
|
+
"",
|
|
6056
|
+
"// The signed-in user, from the platform's own route: null where no one is signed in.",
|
|
6057
|
+
"async function signedInUser() {",
|
|
6058
|
+
" const response = await fetch('/__account/me');",
|
|
6059
|
+
" if (response.status === 200) return (await response.json()).user;",
|
|
6060
|
+
" if (response.status === 404) {",
|
|
6061
|
+
` byId('who').textContent = "This address serves no sign-in. Sign-in is on the application's deployed address; a local run's API routes take a session as a Bearer.";`,
|
|
6062
|
+
" return undefined;",
|
|
6063
|
+
" }",
|
|
6064
|
+
" return null;",
|
|
6065
|
+
"}",
|
|
6066
|
+
"",
|
|
6067
|
+
"async function showRows() {",
|
|
6068
|
+
" const response = await fetch(ROUTE);",
|
|
6069
|
+
" const list = byId('rows');",
|
|
6070
|
+
" list.replaceChildren();",
|
|
6071
|
+
" if (response.status !== 200) return;",
|
|
6072
|
+
" for (const row of await response.json()) {",
|
|
6073
|
+
" const item = document.createElement('li');",
|
|
6074
|
+
" item.textContent = FIELDS.map((field) => (FIELDS.length === 1 ? String(row[field.name]) : `${field.name}: ${row[field.name]}`)).join(', ');",
|
|
6075
|
+
" list.append(item);",
|
|
6076
|
+
" }",
|
|
6077
|
+
"}",
|
|
6078
|
+
"",
|
|
6079
|
+
"function buildForm() {",
|
|
6080
|
+
" const inputs = FIELDS.map((field) => {",
|
|
6081
|
+
" const label = document.createElement('label');",
|
|
6082
|
+
" label.textContent = field.name;",
|
|
6083
|
+
" const input = document.createElement('input');",
|
|
6084
|
+
" input.type = INPUT_TYPES[field.kind];",
|
|
6085
|
+
" input.name = field.name;",
|
|
6086
|
+
" if (field.kind !== 'boolean') input.required = true;",
|
|
6087
|
+
" label.append(input);",
|
|
6088
|
+
" byId('fields').append(label);",
|
|
6089
|
+
" return input;",
|
|
6090
|
+
" });",
|
|
6091
|
+
" byId('add').addEventListener('submit', async (event) => {",
|
|
6092
|
+
" event.preventDefault();",
|
|
6093
|
+
" const row = Object.fromEntries(FIELDS.map((field, index) => [field.name, valueOf(field, inputs[index])]));",
|
|
6094
|
+
" const response = await fetch(ROUTE, { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify(row) });",
|
|
6095
|
+
" const error = byId('add-error');",
|
|
6096
|
+
" error.hidden = response.status === 201;",
|
|
6097
|
+
" if (response.status !== 201) {",
|
|
6098
|
+
" error.textContent = `The row was not added (status ${response.status}).`;",
|
|
6099
|
+
" return;",
|
|
6100
|
+
" }",
|
|
6101
|
+
" byId('add').reset();",
|
|
6102
|
+
" await showRows();",
|
|
6103
|
+
" });",
|
|
6104
|
+
"}",
|
|
6105
|
+
"",
|
|
6106
|
+
"function buildAsk() {",
|
|
6107
|
+
" byId('ask').addEventListener('submit', async (event) => {",
|
|
6108
|
+
" event.preventDefault();",
|
|
6109
|
+
" const answer = byId('answer');",
|
|
6110
|
+
" answer.textContent = 'Asking.';",
|
|
6111
|
+
" const response = await fetch('/api/ask', { method: 'POST', headers: { 'content-type': 'application/json' }, body: JSON.stringify({ question: byId('question').value }) });",
|
|
6112
|
+
" if (response.status === 200) answer.textContent = (await response.json()).answer;",
|
|
6113
|
+
" else if (response.status === 503) answer.textContent = UNANSWERED[(await response.json()).error] ?? 'The AI model gave no answer now.';",
|
|
6114
|
+
" else answer.textContent = `The question was not answered (status ${response.status}).`;",
|
|
6115
|
+
" });",
|
|
6116
|
+
"}",
|
|
6117
|
+
"",
|
|
6118
|
+
"async function start() {",
|
|
6119
|
+
" const user = await signedInUser();",
|
|
6120
|
+
" if (user === undefined) return;",
|
|
6121
|
+
" if (user === null) {",
|
|
6122
|
+
" byId('who').textContent = 'No one is signed in.';",
|
|
6123
|
+
" byId('sign-in').hidden = false;",
|
|
6124
|
+
" return;",
|
|
6125
|
+
" }",
|
|
6126
|
+
" const address = user.addresses?.find((held) => held.verified)?.email;",
|
|
6127
|
+
" byId('who').textContent = `Signed in as ${address ?? user.id}.`;",
|
|
6128
|
+
" byId('sign-out').hidden = false;",
|
|
6129
|
+
" byId('rows-title').textContent = `Your ${PLURAL}`;",
|
|
6130
|
+
" byId('signed-in').hidden = false;",
|
|
6131
|
+
" buildForm();",
|
|
6132
|
+
" buildAsk();",
|
|
6133
|
+
" await showRows();",
|
|
6134
|
+
"}",
|
|
6135
|
+
"",
|
|
6136
|
+
"start();",
|
|
6137
|
+
""
|
|
6138
|
+
].join("\n")],
|
|
6139
|
+
["client/index.html", [
|
|
6140
|
+
"<!doctype html>",
|
|
6141
|
+
'<html lang="en">',
|
|
6142
|
+
"<head>",
|
|
6143
|
+
' <meta charset="utf-8">',
|
|
6144
|
+
' <meta name="viewport" content="width=device-width, initial-scale=1">',
|
|
6145
|
+
" <title>Web app</title>",
|
|
6146
|
+
' <link rel="stylesheet" href="/styles.css">',
|
|
6147
|
+
"</head>",
|
|
6148
|
+
"<body>",
|
|
6149
|
+
" <header>",
|
|
6150
|
+
" <h1>Web app</h1>",
|
|
6151
|
+
' <p id="who">Reading who is signed in.</p>',
|
|
6152
|
+
" <nav>",
|
|
6153
|
+
' <a id="sign-in" href="/__account/signin" hidden>Sign in</a>',
|
|
6154
|
+
' <a id="sign-out" href="/__account/signout" hidden>Sign out</a>',
|
|
6155
|
+
" </nav>",
|
|
6156
|
+
" </header>",
|
|
6157
|
+
' <main id="signed-in" hidden>',
|
|
6158
|
+
" <section>",
|
|
6159
|
+
' <h2 id="rows-title">Your rows</h2>',
|
|
6160
|
+
' <form id="add">',
|
|
6161
|
+
' <div id="fields"></div>',
|
|
6162
|
+
' <button type="submit">Add</button>',
|
|
6163
|
+
" </form>",
|
|
6164
|
+
' <p id="add-error" class="error" hidden></p>',
|
|
6165
|
+
' <ul id="rows"></ul>',
|
|
6166
|
+
" </section>",
|
|
6167
|
+
" <section>",
|
|
6168
|
+
" <h2>Ask the AI model</h2>",
|
|
6169
|
+
' <form id="ask">',
|
|
6170
|
+
' <textarea id="question" rows="3" required></textarea>',
|
|
6171
|
+
' <button type="submit">Ask</button>',
|
|
6172
|
+
" </form>",
|
|
6173
|
+
' <p id="answer"></p>',
|
|
6174
|
+
" </section>",
|
|
6175
|
+
" </main>",
|
|
6176
|
+
' <script src="/app.js"></script>',
|
|
6177
|
+
"</body>",
|
|
6178
|
+
"</html>",
|
|
6179
|
+
""
|
|
6180
|
+
].join("\n")],
|
|
6181
|
+
["client/styles.css", [
|
|
6182
|
+
"/* client/styles.css */",
|
|
6183
|
+
":root {",
|
|
6184
|
+
" color-scheme: light dark;",
|
|
6185
|
+
" font-family: system-ui, sans-serif;",
|
|
6186
|
+
" line-height: 1.5;",
|
|
6187
|
+
"}",
|
|
6188
|
+
"",
|
|
6189
|
+
"body {",
|
|
6190
|
+
" max-width: 40rem;",
|
|
6191
|
+
" margin: 0 auto;",
|
|
6192
|
+
" padding: 1rem;",
|
|
6193
|
+
"}",
|
|
6194
|
+
"",
|
|
6195
|
+
"header nav a {",
|
|
6196
|
+
" margin-right: 1rem;",
|
|
6197
|
+
"}",
|
|
6198
|
+
"",
|
|
6199
|
+
"form {",
|
|
6200
|
+
" display: grid;",
|
|
6201
|
+
" gap: 0.5rem;",
|
|
6202
|
+
" margin: 1rem 0;",
|
|
6203
|
+
"}",
|
|
6204
|
+
"",
|
|
6205
|
+
"label {",
|
|
6206
|
+
" display: grid;",
|
|
6207
|
+
" gap: 0.25rem;",
|
|
6208
|
+
"}",
|
|
6209
|
+
"",
|
|
6210
|
+
"input,",
|
|
6211
|
+
"textarea,",
|
|
6212
|
+
"button {",
|
|
6213
|
+
" font: inherit;",
|
|
6214
|
+
" padding: 0.4rem;",
|
|
6215
|
+
"}",
|
|
6216
|
+
"",
|
|
6217
|
+
"button {",
|
|
6218
|
+
" justify-self: start;",
|
|
6219
|
+
"}",
|
|
6220
|
+
"",
|
|
6221
|
+
"ul {",
|
|
6222
|
+
" padding-left: 1.2rem;",
|
|
6223
|
+
"}",
|
|
6224
|
+
"",
|
|
6225
|
+
".error {",
|
|
6226
|
+
" color: #b00020;",
|
|
6227
|
+
"}",
|
|
6228
|
+
""
|
|
6229
|
+
].join("\n")],
|
|
6230
|
+
["manifest.json", [
|
|
6231
|
+
"{",
|
|
6232
|
+
' "manifest_version": 1,',
|
|
6233
|
+
' "services": [{ "kind": "database" }, { "kind": "accounts" }],',
|
|
6234
|
+
' "health": "/health",',
|
|
6235
|
+
' "region": "usa",',
|
|
6236
|
+
' "egress": [],',
|
|
6237
|
+
' "audience": { "kind": "public" },',
|
|
6238
|
+
' "packages": []',
|
|
6239
|
+
"}",
|
|
6240
|
+
""
|
|
6241
|
+
].join("\n")],
|
|
6242
|
+
["migrations/0001_notes.sql", [
|
|
6243
|
+
"-- migrations/0001_notes.sql: each row is kept for the user who added it, its owner.",
|
|
6244
|
+
'CREATE TABLE "notes" (',
|
|
6245
|
+
" id bigserial PRIMARY KEY,",
|
|
6246
|
+
" owner text NOT NULL,",
|
|
6247
|
+
" title text NOT NULL CHECK (title <> '')",
|
|
6248
|
+
");",
|
|
6249
|
+
'CREATE INDEX ON "notes" (owner, id);',
|
|
6250
|
+
""
|
|
6251
|
+
].join("\n")],
|
|
6252
|
+
["package.json", [
|
|
6253
|
+
"{",
|
|
6254
|
+
' "name": "web-app",',
|
|
6255
|
+
' "private": true,',
|
|
6256
|
+
' "type": "module",',
|
|
6257
|
+
' "scripts": {',
|
|
6258
|
+
' "start": "node src/main.mjs",',
|
|
6259
|
+
' "test": "node --test"',
|
|
6260
|
+
" },",
|
|
6261
|
+
' "dependencies": {',
|
|
6262
|
+
' "@turnzero/account": "file:lib/account",',
|
|
6263
|
+
' "@turnzero/ai_allowance": "file:lib/ai_allowance",',
|
|
6264
|
+
' "@turnzero/database": "file:lib/database",',
|
|
6265
|
+
' "pg": "^8.22.0"',
|
|
6266
|
+
" },",
|
|
6267
|
+
' "devDependencies": {',
|
|
6268
|
+
' "@electric-sql/pglite": "0.5.8"',
|
|
6269
|
+
" }",
|
|
6270
|
+
"}",
|
|
6271
|
+
""
|
|
6272
|
+
].join("\n")],
|
|
6273
|
+
["src/ai.mjs", [
|
|
6274
|
+
"// src/ai.mjs: an answer from an AI model under the plan's AI allowance, with",
|
|
6275
|
+
"// no key of your own, as the guide Call an AI model under the allowance shows.",
|
|
6276
|
+
"import { AllowanceRefusal, AllowanceUpstreamError, generateText } from '@turnzero/ai_allowance';",
|
|
6277
|
+
"",
|
|
6278
|
+
"// The live transport, which only the process entry opens: the platform's",
|
|
6279
|
+
"// origin and this copy's credential, from the settings it receives. A local",
|
|
6280
|
+
"// run receives the development credential, so the same transport works there.",
|
|
6281
|
+
"export function liveTransport() {",
|
|
6282
|
+
" const origin = process.env.TURNZERO_CLOUD_GATEWAY_URL ?? process.env.TURNZERO_CLOUD_API;",
|
|
6283
|
+
" return (path, init = {}) =>",
|
|
6284
|
+
" fetch(origin + path, { ...init, headers: { ...init.headers, authorization: `Bearer ${process.env.TURNZERO_CLOUD_TOKEN}` } });",
|
|
6285
|
+
"}",
|
|
6286
|
+
"",
|
|
6287
|
+
"// The asker over any transport: the live one, or a test double's. It answers",
|
|
6288
|
+
"// { answer } for one question, or { unanswered } naming why the model gave",
|
|
6289
|
+
"// none: allowance_drawn, the month's units are drawn (the refusal",
|
|
6290
|
+
"// allowance_exhausted); allowance_unavailable, the platform's key is not in",
|
|
6291
|
+
"// place; or upstream_quota, the provider is out of capacity.",
|
|
6292
|
+
"export function createAsker(transport) {",
|
|
6293
|
+
" return async (question) => {",
|
|
6294
|
+
" try {",
|
|
6295
|
+
" const answer = await generateText(transport, {",
|
|
6296
|
+
" system: 'Answer the question in a few sentences.',",
|
|
6297
|
+
" messages: [{ role: 'user', content: question }],",
|
|
6298
|
+
" maxOutputTokens: 400,",
|
|
6299
|
+
" });",
|
|
6300
|
+
" const { used, quota } = answer.allowance;",
|
|
6301
|
+
" if (typeof used === 'number' && typeof quota === 'number') console.log(`AI allowance: ${quota - used} of ${quota} units left before this call`);",
|
|
6302
|
+
" return { answer: answer.text };",
|
|
6303
|
+
" } catch (error) {",
|
|
6304
|
+
" // Choose the fallback by the refusal's name, never by its message.",
|
|
6305
|
+
" const refusal = error instanceof AllowanceRefusal ? error.refusal : null;",
|
|
6306
|
+
" if (refusal === 'allowance_exhausted') return { unanswered: 'allowance_drawn' };",
|
|
6307
|
+
" if (refusal === 'allowance_unavailable') return { unanswered: 'allowance_unavailable' };",
|
|
6308
|
+
" const providerFull = error instanceof AllowanceUpstreamError && error.quota;",
|
|
6309
|
+
" if (providerFull) return { unanswered: 'upstream_quota' };",
|
|
6310
|
+
" throw error;",
|
|
6311
|
+
" }",
|
|
6312
|
+
" };",
|
|
6313
|
+
"}",
|
|
6314
|
+
""
|
|
6315
|
+
].join("\n")],
|
|
6316
|
+
["src/db.mjs", [
|
|
6317
|
+
"// src/db.mjs",
|
|
6318
|
+
"import { readdirSync, readFileSync } from 'node:fs';",
|
|
6319
|
+
"import { join } from 'node:path';",
|
|
6320
|
+
"import { fileURLToPath } from 'node:url';",
|
|
6321
|
+
"import pg from 'pg';",
|
|
6322
|
+
"import { bootWalk, createFailoverPool } from '@turnzero/database';",
|
|
6323
|
+
"",
|
|
6324
|
+
"const MIGRATIONS = fileURLToPath(new URL('../migrations/', import.meta.url));",
|
|
6325
|
+
"const HEALTH_READ_MS = 2_000;",
|
|
6326
|
+
"",
|
|
6327
|
+
"// The live pool, which only the process entry opens: the package's failover",
|
|
6328
|
+
"// pool over a pg pool. Its maximum is the plan's connection_limit, which the",
|
|
6329
|
+
"// platform injects and the turnzero command's provision writes; one",
|
|
6330
|
+
"// connection where the setting is absent.",
|
|
6331
|
+
"export function openLivePool() {",
|
|
6332
|
+
" const url = process.env.APP_DATABASE_URL;",
|
|
6333
|
+
" if (!url) throw new Error('APP_DATABASE_URL is not set');",
|
|
6334
|
+
" const limit = process.env.APP_DATABASE_CONNECTION_LIMIT ?? '';",
|
|
6335
|
+
" const max = /^[1-9][0-9]*$/.test(limit) ? Number(limit) : 1;",
|
|
6336
|
+
" return createFailoverPool({",
|
|
6337
|
+
" // settings is { max, connectionTimeoutMillis }: the maximum and the five-second connect limit.",
|
|
6338
|
+
" connect: (settings) => new pg.Pool({ connectionString: url, ...settings }),",
|
|
6339
|
+
" max,",
|
|
6340
|
+
" onConnectionLoss: (error) => console.warn('database session ended:', error.message),",
|
|
6341
|
+
" });",
|
|
6342
|
+
"}",
|
|
6343
|
+
"",
|
|
6344
|
+
"// The migrations through the package's boot walk: a lost connection retried",
|
|
6345
|
+
"// every second for up to 120 seconds, and any other failure ends the start.",
|
|
6346
|
+
"export function migrate(pool) {",
|
|
6347
|
+
" const files = { list: () => readdirSync(MIGRATIONS), read: (name) => readFileSync(join(MIGRATIONS, name), 'utf8') };",
|
|
6348
|
+
" return bootWalk(pool, files, {",
|
|
6349
|
+
" onRetry: (error, attempt) => {",
|
|
6350
|
+
" if (attempt === 1) console.warn('database unreachable at start; retrying:', error.message);",
|
|
6351
|
+
" },",
|
|
6352
|
+
" });",
|
|
6353
|
+
"}",
|
|
6354
|
+
"",
|
|
6355
|
+
"// The data layer over any pool with the pg.Pool shape: the live one, or a test double's.",
|
|
6356
|
+
"// Each row belongs to the signed-in user who added it, its owner, and is listed to that user alone.",
|
|
6357
|
+
"export function createDataLayer(pool) {",
|
|
6358
|
+
" return {",
|
|
6359
|
+
" // One read over the connection, bounded so a probe never waits on a lost database.",
|
|
6360
|
+
" async reachable() {",
|
|
6361
|
+
" let timer;",
|
|
6362
|
+
" const late = new Promise((resolve) => { timer = setTimeout(resolve, HEALTH_READ_MS, 'late'); });",
|
|
6363
|
+
" try {",
|
|
6364
|
+
" return (await Promise.race([pool.query('SELECT 1'), late])) !== 'late';",
|
|
6365
|
+
" } catch {",
|
|
6366
|
+
" return false;",
|
|
6367
|
+
" } finally {",
|
|
6368
|
+
" clearTimeout(timer);",
|
|
6369
|
+
" }",
|
|
6370
|
+
" },",
|
|
6371
|
+
" async addNote(owner, title) {",
|
|
6372
|
+
` const { rows } = await pool.query('INSERT INTO "notes" (owner, title) VALUES ($1, $2) RETURNING id, title', [owner, title]);`,
|
|
6373
|
+
" return rows[0];",
|
|
6374
|
+
" },",
|
|
6375
|
+
" async listNotes(owner) {",
|
|
6376
|
+
` const { rows } = await pool.query('SELECT id, title FROM "notes" WHERE owner = $1 ORDER BY id', [owner]);`,
|
|
6377
|
+
" return rows;",
|
|
6378
|
+
" },",
|
|
6379
|
+
" };",
|
|
6380
|
+
"}",
|
|
6381
|
+
""
|
|
6382
|
+
].join("\n")],
|
|
6383
|
+
["src/main.mjs", [
|
|
6384
|
+
"// src/main.mjs, the process entry the start script runs",
|
|
6385
|
+
"import { createAsker, liveTransport } from './ai.mjs';",
|
|
6386
|
+
"import { createDataLayer, migrate, openLivePool } from './db.mjs';",
|
|
6387
|
+
"import { createUserReader, openLiveAccounts } from './session.mjs';",
|
|
6388
|
+
"import { createAppServer } from './server.mjs';",
|
|
6389
|
+
"",
|
|
6390
|
+
"const pool = openLivePool();",
|
|
6391
|
+
"const applied = await migrate(pool);",
|
|
6392
|
+
"console.log(applied.length > 0 ? `migrations applied at this start: ${applied.join(', ')}` : 'migrations: none pending at this start');",
|
|
6393
|
+
"const port = Number(process.env.PORT ?? 8080);",
|
|
6394
|
+
"const server = createAppServer({ db: createDataLayer(pool), userIdOf: createUserReader(openLiveAccounts()), ask: createAsker(liveTransport()) });",
|
|
6395
|
+
"server.listen(port, () => console.log(`listening on ${port}`));",
|
|
6396
|
+
""
|
|
6397
|
+
].join("\n")],
|
|
6398
|
+
["src/server.mjs", [
|
|
6399
|
+
"// src/server.mjs",
|
|
6400
|
+
"import { createServer } from 'node:http';",
|
|
6401
|
+
"import { readFile } from 'node:fs/promises';",
|
|
6402
|
+
"",
|
|
6403
|
+
"// The web client's three files, by the path a browser asks for them. On the",
|
|
6404
|
+
"// platform, the deploy serves them from client/ ahead of these routes. A",
|
|
6405
|
+
"// local run has nothing in front of it, so this server answers them itself.",
|
|
6406
|
+
"const CLIENT_FILES = Object.freeze({",
|
|
6407
|
+
" '/': ['index.html', 'text/html; charset=utf-8'],",
|
|
6408
|
+
" '/index.html': ['index.html', 'text/html; charset=utf-8'],",
|
|
6409
|
+
" '/app.js': ['app.js', 'text/javascript; charset=utf-8'],",
|
|
6410
|
+
" '/styles.css': ['styles.css', 'text/css; charset=utf-8'],",
|
|
6411
|
+
"});",
|
|
6412
|
+
"",
|
|
6413
|
+
"const readBody = async (request) => {",
|
|
6414
|
+
" // One decoder for the whole body: a character whose bytes arrive in two chunks is read whole.",
|
|
6415
|
+
" request.setEncoding('utf8');",
|
|
6416
|
+
" let text = '';",
|
|
6417
|
+
" for await (const chunk of request) text += chunk;",
|
|
6418
|
+
" return text;",
|
|
6419
|
+
"};",
|
|
6420
|
+
"",
|
|
6421
|
+
"// The server over its three parts: db, the data layer; userIdOf, which reads",
|
|
6422
|
+
"// the signed-in user's id from a request, or null; and ask, which answers a",
|
|
6423
|
+
"// question under the AI allowance, or names why the model gave no answer.",
|
|
6424
|
+
"export function createAppServer({ db, userIdOf, ask }) {",
|
|
6425
|
+
" return createServer(async (request, response) => {",
|
|
6426
|
+
" // The deployed runtime's abort signal: end the response when it fires.",
|
|
6427
|
+
" request.signal?.addEventListener('abort', () => response.destroy(), { once: true });",
|
|
6428
|
+
" const send = (status, body) => {",
|
|
6429
|
+
" if (response.destroyed) return;",
|
|
6430
|
+
" response.writeHead(status, { 'content-type': 'application/json' });",
|
|
6431
|
+
" response.end(JSON.stringify(body));",
|
|
6432
|
+
" };",
|
|
6433
|
+
" try {",
|
|
6434
|
+
" // A target that does not parse as a URL names no route, so it is answered 404 below.",
|
|
6435
|
+
" let pathname = null;",
|
|
6436
|
+
" try {",
|
|
6437
|
+
" pathname = new URL(request.url, 'http://localhost').pathname;",
|
|
6438
|
+
" } catch {}",
|
|
6439
|
+
" if (request.method === 'GET' && pathname === '/health') {",
|
|
6440
|
+
" return (await db.reachable()) ? send(200, { ok: true }) : send(503, { ok: false });",
|
|
6441
|
+
" }",
|
|
6442
|
+
" if (request.method === 'GET' && Object.hasOwn(CLIENT_FILES, pathname)) {",
|
|
6443
|
+
" const [file, type] = CLIENT_FILES[pathname];",
|
|
6444
|
+
" try {",
|
|
6445
|
+
" const body = await readFile(new URL(`../client/${file}`, import.meta.url));",
|
|
6446
|
+
" if (!response.destroyed) response.writeHead(200, { 'content-type': type }).end(body);",
|
|
6447
|
+
" return;",
|
|
6448
|
+
" } catch (error) {",
|
|
6449
|
+
" if (error?.code === 'ENOENT') return send(404, { error: 'not_found' });",
|
|
6450
|
+
" throw error;",
|
|
6451
|
+
" }",
|
|
6452
|
+
" }",
|
|
6453
|
+
" // Every route under /api/ answers the signed-in user alone.",
|
|
6454
|
+
" if (pathname === '/api/notes' || pathname === '/api/ask') {",
|
|
6455
|
+
" const user = await userIdOf(request);",
|
|
6456
|
+
" if (user === null) return send(401, { error: 'sign_in_required' });",
|
|
6457
|
+
" if (request.method === 'GET' && pathname === '/api/notes') return send(200, await db.listNotes(user));",
|
|
6458
|
+
" if (request.method === 'POST' && pathname === '/api/notes') {",
|
|
6459
|
+
" const posted = JSON.parse(await readBody(request));",
|
|
6460
|
+
" return send(201, await db.addNote(user, String(posted?.title ?? '')));",
|
|
6461
|
+
" }",
|
|
6462
|
+
" if (request.method === 'POST' && pathname === '/api/ask') {",
|
|
6463
|
+
" let question = '';",
|
|
6464
|
+
" try {",
|
|
6465
|
+
" question = String(JSON.parse(await readBody(request))?.question ?? '').trim();",
|
|
6466
|
+
" } catch {}",
|
|
6467
|
+
" if (question === '') return send(400, { error: 'invalid_question' });",
|
|
6468
|
+
" // 503 names why the model gave no answer: allowance_drawn,",
|
|
6469
|
+
" // allowance_unavailable, or upstream_quota (src/ai.mjs).",
|
|
6470
|
+
" const asked = await ask(question);",
|
|
6471
|
+
" return 'answer' in asked ? send(200, { answer: asked.answer }) : send(503, { error: asked.unanswered });",
|
|
6472
|
+
" }",
|
|
6473
|
+
" }",
|
|
6474
|
+
" send(404, { error: 'not_found' });",
|
|
6475
|
+
" } catch (error) {",
|
|
6476
|
+
" const refused = error instanceof SyntaxError || error?.code === '23514';",
|
|
6477
|
+
" if (!refused) console.error(error);",
|
|
6478
|
+
" send(refused ? 400 : 500, { error: refused ? 'invalid_note' : 'internal' });",
|
|
6479
|
+
" }",
|
|
6480
|
+
" });",
|
|
6481
|
+
"}",
|
|
6482
|
+
""
|
|
6483
|
+
].join("\n")],
|
|
6484
|
+
["src/session.mjs", [
|
|
6485
|
+
"// src/session.mjs: the signed-in user who sent a request, as the guide Add",
|
|
6486
|
+
"// sign-in to your app shows it.",
|
|
6487
|
+
"import { makeVerifyClient } from '@turnzero/account';",
|
|
6488
|
+
"",
|
|
6489
|
+
"// The cookie a signed-in browser sends with each request to the application's address.",
|
|
6490
|
+
"const SESSION_COOKIE = '__Host-turnzero_cloud_realm';",
|
|
6491
|
+
"",
|
|
6492
|
+
"// The live client, which only the process entry opens. A deployed copy",
|
|
6493
|
+
"// receives all three settings. A local run has no TURNZERO_CLOUD_REALM_KEYS,",
|
|
6494
|
+
"// so the client is built without keys there and verifies through the",
|
|
6495
|
+
"// platform's route.",
|
|
6496
|
+
"export function openLiveAccounts() {",
|
|
6497
|
+
" const keys = process.env.TURNZERO_CLOUD_REALM_KEYS;",
|
|
6498
|
+
" return makeVerifyClient({",
|
|
6499
|
+
" baseUrl: process.env.TURNZERO_CLOUD_API,",
|
|
6500
|
+
" credential: process.env.TURNZERO_CLOUD_TOKEN,",
|
|
6501
|
+
" ...(keys ? { realmKeys: keys } : {}),",
|
|
6502
|
+
" });",
|
|
6503
|
+
"}",
|
|
6504
|
+
"",
|
|
6505
|
+
"// The session token: the cookie's value from a browser, or the bearer from a",
|
|
6506
|
+
"// native app or a local run.",
|
|
6507
|
+
"export function sessionOf(request) {",
|
|
6508
|
+
" const auth = request.headers.authorization ?? '';",
|
|
6509
|
+
" if (auth.startsWith('Bearer ')) return auth.slice('Bearer '.length);",
|
|
6510
|
+
" for (const part of (request.headers.cookie ?? '').split(';')) {",
|
|
6511
|
+
" const at = part.indexOf('=');",
|
|
6512
|
+
" if (at > 0 && part.slice(0, at).trim() === SESSION_COOKIE) return part.slice(at + 1).trim();",
|
|
6513
|
+
" }",
|
|
6514
|
+
" return null;",
|
|
6515
|
+
"}",
|
|
6516
|
+
"",
|
|
6517
|
+
"// The reader of a request's user: the signed-in user's id, or null with no",
|
|
6518
|
+
"// live session. The token is verified by the Account package's client, with",
|
|
6519
|
+
"// the x-turnzero-cloud-session header the serving router sets on each",
|
|
6520
|
+
"// request it forwards.",
|
|
6521
|
+
"export function createUserReader(accounts) {",
|
|
6522
|
+
" return async (request) => {",
|
|
6523
|
+
" const verdict = await accounts.verifySession(sessionOf(request), {",
|
|
6524
|
+
" sessionHeader: request.headers['x-turnzero-cloud-session'] ?? null,",
|
|
6525
|
+
" });",
|
|
6526
|
+
" return 'user' in verdict ? verdict.user.id : null;",
|
|
6527
|
+
" };",
|
|
6528
|
+
"}",
|
|
6529
|
+
""
|
|
6530
|
+
].join("\n")],
|
|
6531
|
+
["test/app.test.mjs", [
|
|
6532
|
+
"// test/app.test.mjs",
|
|
6533
|
+
"import assert from 'node:assert/strict';",
|
|
6534
|
+
"import { after, before, beforeEach, test } from 'node:test';",
|
|
6535
|
+
"import { fileURLToPath } from 'node:url';",
|
|
6536
|
+
"import { PGlite } from '@electric-sql/pglite';",
|
|
6537
|
+
"import { createAccountDouble } from '@turnzero/account/testing';",
|
|
6538
|
+
"import { createAiAllowanceDouble } from '@turnzero/ai_allowance/testing';",
|
|
6539
|
+
"import { createDatabaseDouble, migrationsFrom } from '@turnzero/database/testing';",
|
|
6540
|
+
"import { createAsker } from '../src/ai.mjs';",
|
|
6541
|
+
"import { createDataLayer } from '../src/db.mjs';",
|
|
6542
|
+
"import { createAppServer } from '../src/server.mjs';",
|
|
6543
|
+
"import { createUserReader } from '../src/session.mjs';",
|
|
6544
|
+
"",
|
|
6545
|
+
"const MIGRATIONS = fileURLToPath(new URL('../migrations/', import.meta.url));",
|
|
6546
|
+
"",
|
|
6547
|
+
"// The packages' three test doubles for the file: a PostgreSQL engine inside",
|
|
6548
|
+
"// this process, with the migrations applied; the realm's sign-in; and the AI",
|
|
6549
|
+
"// allowance. The server runs over them on a port of this machine.",
|
|
6550
|
+
"let double;",
|
|
6551
|
+
"let accounts;",
|
|
6552
|
+
"let ai;",
|
|
6553
|
+
"let db;",
|
|
6554
|
+
"let server;",
|
|
6555
|
+
"let base;",
|
|
6556
|
+
"// Two signed-in users, and the headers a browser of each sends.",
|
|
6557
|
+
"let sam;",
|
|
6558
|
+
"let kim;",
|
|
6559
|
+
"const signedIn = (user) => ({ cookie: `__Host-turnzero_cloud_realm=${user.session}`, 'x-turnzero-cloud-session': user.header });",
|
|
6560
|
+
"",
|
|
6561
|
+
"before(async () => {",
|
|
6562
|
+
" double = await createDatabaseDouble({ engine: new PGlite(), files: migrationsFrom(MIGRATIONS) });",
|
|
6563
|
+
" accounts = await createAccountDouble();",
|
|
6564
|
+
" ai = createAiAllowanceDouble();",
|
|
6565
|
+
" db = createDataLayer(double.pool);",
|
|
6566
|
+
" server = createAppServer({ db, userIdOf: createUserReader(accounts.client()), ask: createAsker(ai.transport) });",
|
|
6567
|
+
" await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve));",
|
|
6568
|
+
" base = `http://127.0.0.1:${server.address().port}`;",
|
|
6569
|
+
" sam = accounts.signIn({ id: 'user-sam' });",
|
|
6570
|
+
" kim = accounts.signIn({ id: 'user-kim' });",
|
|
6571
|
+
"});",
|
|
6572
|
+
"",
|
|
6573
|
+
"// Every table emptied and the allowance's answers cleared between tests, so each test starts from empty.",
|
|
6574
|
+
"beforeEach(() => {",
|
|
6575
|
+
" ai.reset();",
|
|
6576
|
+
" return double.reset();",
|
|
6577
|
+
"});",
|
|
6578
|
+
"",
|
|
6579
|
+
"after(async () => {",
|
|
6580
|
+
" await new Promise((resolve) => server.close(resolve));",
|
|
6581
|
+
" await double.close();",
|
|
6582
|
+
"});",
|
|
6583
|
+
"",
|
|
6584
|
+
"test('the migration applies', () => {",
|
|
6585
|
+
" assert.deepEqual(double.applied, ['0001_notes.sql']);",
|
|
6586
|
+
"});",
|
|
6587
|
+
"",
|
|
6588
|
+
"test('the data layer adds a row for its owner and lists it to that owner alone', async () => {",
|
|
6589
|
+
" assert.deepEqual(await db.addNote('user-sam', 'first'), { id: '1', title: 'first' });",
|
|
6590
|
+
" assert.deepEqual(await db.listNotes('user-sam'), [{ id: '1', title: 'first' }]);",
|
|
6591
|
+
" assert.deepEqual(await db.listNotes('user-kim'), []);",
|
|
6592
|
+
"});",
|
|
6593
|
+
"",
|
|
6594
|
+
"test('GET /health reads the database and returns 200', async () => {",
|
|
6595
|
+
" const response = await fetch(`${base}/health`);",
|
|
6596
|
+
" assert.equal(response.status, 200);",
|
|
6597
|
+
" assert.deepEqual(await response.json(), { ok: true });",
|
|
6598
|
+
"});",
|
|
6599
|
+
"",
|
|
6600
|
+
"test('a local run serves the page, its script, and its stylesheet from client/', async () => {",
|
|
6601
|
+
" for (const [path, type] of [['/', 'text/html'], ['/app.js', 'text/javascript'], ['/styles.css', 'text/css']]) {",
|
|
6602
|
+
" const response = await fetch(`${base}${path}`);",
|
|
6603
|
+
" assert.equal(response.status, 200, path);",
|
|
6604
|
+
" assert.ok(response.headers.get('content-type').startsWith(type), path);",
|
|
6605
|
+
" assert.ok((await response.text()).length > 0, path);",
|
|
6606
|
+
" }",
|
|
6607
|
+
"});",
|
|
6608
|
+
"",
|
|
6609
|
+
"test('the API answers 401 to a request with no session, and reads a session sent as a Bearer', async () => {",
|
|
6610
|
+
" for (const path of ['/api/notes', '/api/ask']) {",
|
|
6611
|
+
" const response = await fetch(`${base}${path}`, { method: path === '/api/ask' ? 'POST' : 'GET' });",
|
|
6612
|
+
" assert.equal(response.status, 401, path);",
|
|
6613
|
+
" assert.deepEqual(await response.json(), { error: 'sign_in_required' });",
|
|
6614
|
+
" }",
|
|
6615
|
+
" const bearer = await fetch(`${base}/api/notes`, { headers: { authorization: `Bearer ${sam.session}`, 'x-turnzero-cloud-session': sam.header } });",
|
|
6616
|
+
" assert.equal(bearer.status, 200);",
|
|
6617
|
+
"});",
|
|
6618
|
+
"",
|
|
6619
|
+
"test('POST /api/notes keeps a title with characters outside ASCII for the signed-in user, and GET /api/notes returns it to that user alone', async () => {",
|
|
6620
|
+
" const title = 'caf\xE9 \u2615 \u6771\u4EAC';",
|
|
6621
|
+
" const response = await fetch(`${base}/api/notes`, {",
|
|
6622
|
+
" method: 'POST',",
|
|
6623
|
+
" headers: { 'content-type': 'application/json', ...signedIn(sam) },",
|
|
6624
|
+
" body: JSON.stringify({ title }),",
|
|
6625
|
+
" });",
|
|
6626
|
+
" assert.equal(response.status, 201);",
|
|
6627
|
+
" assert.equal((await response.json()).title, title);",
|
|
6628
|
+
" const listed = await fetch(`${base}/api/notes`, { headers: signedIn(sam) });",
|
|
6629
|
+
" assert.equal(listed.status, 200);",
|
|
6630
|
+
" assert.deepEqual((await listed.json()).map((row) => row.title), [title]);",
|
|
6631
|
+
" assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(kim) })).json(), []);",
|
|
6632
|
+
"});",
|
|
6633
|
+
"",
|
|
6634
|
+
"test('POST /api/notes refuses an empty title, and GET /api/notes returns no row', async () => {",
|
|
6635
|
+
" const response = await fetch(`${base}/api/notes`, {",
|
|
6636
|
+
" method: 'POST',",
|
|
6637
|
+
" headers: { 'content-type': 'application/json', ...signedIn(sam) },",
|
|
6638
|
+
" body: JSON.stringify({ title: '' }),",
|
|
6639
|
+
" });",
|
|
6640
|
+
" assert.equal(response.status, 400);",
|
|
6641
|
+
" assert.deepEqual(await response.json(), { error: 'invalid_note' });",
|
|
6642
|
+
" assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(sam) })).json(), []);",
|
|
6643
|
+
"});",
|
|
6644
|
+
"",
|
|
6645
|
+
"test('POST /api/ask answers under the AI allowance, and answers 503 naming why the model gave no answer: the allowance drawn, no key in place, or the provider out of capacity', async () => {",
|
|
6646
|
+
" ai.prime({ text: 'Lunch is at noon.', usage: { input: 40, output: 12 } });",
|
|
6647
|
+
" const ask = (question) =>",
|
|
6648
|
+
" fetch(`${base}/api/ask`, { method: 'POST', headers: { 'content-type': 'application/json', ...signedIn(sam) }, body: JSON.stringify({ question }) });",
|
|
6649
|
+
" const answered = await ask('When is lunch?');",
|
|
6650
|
+
" assert.equal(answered.status, 200);",
|
|
6651
|
+
" assert.deepEqual(await answered.json(), { answer: 'Lunch is at noon.' });",
|
|
6652
|
+
" const unanswered = async (error) => {",
|
|
6653
|
+
" const response = await ask('When is lunch?');",
|
|
6654
|
+
" assert.equal(response.status, 503, error);",
|
|
6655
|
+
" assert.deepEqual(await response.json(), { error });",
|
|
6656
|
+
" };",
|
|
6657
|
+
" ai.refuseNext('allowance_exhausted');",
|
|
6658
|
+
" await unanswered('allowance_drawn');",
|
|
6659
|
+
" ai.refuseNext('allowance_unavailable');",
|
|
6660
|
+
" await unanswered('allowance_unavailable');",
|
|
6661
|
+
" ai.prime({ status: 429, json: { error: { status: 'RESOURCE_EXHAUSTED' } } });",
|
|
6662
|
+
" await unanswered('upstream_quota');",
|
|
6663
|
+
" const empty = await ask('');",
|
|
6664
|
+
" assert.equal(empty.status, 400);",
|
|
6665
|
+
"});",
|
|
6666
|
+
""
|
|
6667
|
+
].join("\n")]
|
|
5861
6668
|
])
|
|
5862
6669
|
});
|
|
5863
6670
|
|
|
@@ -5869,13 +6676,31 @@ var CREATE_STEP = "Call create_application from your tool for this new service,
|
|
|
5869
6676
|
var runLineOf = (origin, windows) => machineLineOf(commandLineOf(origin, RUN_SUBCOMMAND, origin !== DEFAULT_COMMAND_ORIGIN ? { origin } : {}), windows);
|
|
5870
6677
|
var DEPLOY_STEP = `Call deploy from your tool with the application, naming no environment and neither artifact nor upload, and run the line it answers in ${APPLICATION_FOLDER}/. A new application has one environment, production, so that deploy goes there; call create_environment first and follow its answer to try versions on development.`;
|
|
5871
6678
|
var SHAPE_STEP = `Shape the service to your own before the first deploy: the table and its migration in ${APPLICATION_FOLDER}/migrations, the routes in ${APPLICATION_FOLDER}/src, and the tests, which cover those routes as written.`;
|
|
6679
|
+
var WEB_SHAPE_STEP = `Shape the app to your own before the first deploy: the page in ${APPLICATION_FOLDER}/client, the table and its migration in ${APPLICATION_FOLDER}/migrations, the routes in ${APPLICATION_FOLDER}/src, and the tests, which cover those routes as written.`;
|
|
6680
|
+
var WEB_OPEN_STEP = "Open the address the deploy answers in a browser and sign in there: the page lists the signed-in user's own rows, adds one, and answers a question under the plan's AI allowance.";
|
|
5872
6681
|
var SUBMIT_NO_SETTING_STEP = `Call submit_manifest from your tool with the application and its manifest, ${APPLICATION_FOLDER}/${APPLICATION_MANIFEST}, naming no local_run, since the service reads no setting.`;
|
|
5873
6682
|
var RUN_STEP_BACKGROUND = "The line runs until the application ends, so start it as a background process and read its lines";
|
|
5874
6683
|
var SCHEDULED_JOB_SCHEDULE = "heartbeat";
|
|
6684
|
+
var ENTRY_PAGES = Object.freeze({
|
|
6685
|
+
database: Object.freeze({ follow: "the guide Add a database", page: "/cloud/guides/add-a-database/" }),
|
|
6686
|
+
// The Schedule package's own page: its section What an application must do
|
|
6687
|
+
// says how an application passes its declared handlers to the package's
|
|
6688
|
+
// router, and the page links the guide that declares a schedule.
|
|
6689
|
+
schedule: Object.freeze({ follow: "the Schedule package's page", page: "/cloud/backend-packages/schedule/" }),
|
|
6690
|
+
account: Object.freeze({ follow: "the guide Add sign-in to your app", page: "/cloud/guides/add-sign-in-to-your-app/" }),
|
|
6691
|
+
ai_allowance: Object.freeze({ follow: "the guide Call an AI model under the allowance", page: "/cloud/guides/call-an-ai-model-under-the-allowance/" })
|
|
6692
|
+
});
|
|
6693
|
+
var takesOf = (template) => Object.freeze(
|
|
6694
|
+
SCAFFOLD_TEMPLATE_ENTRIES[template].map(({ entry }) => {
|
|
6695
|
+
const page = ENTRY_PAGES[entry];
|
|
6696
|
+
if (page === void 0) throw new Error(`the ${entry} entry has no page to follow`);
|
|
6697
|
+
return Object.freeze({ entry, ...page });
|
|
6698
|
+
})
|
|
6699
|
+
);
|
|
5875
6700
|
var TEMPLATE_FACTS = Object.freeze({
|
|
5876
6701
|
"hello-world": Object.freeze({
|
|
5877
6702
|
is: "the smallest service that deploys, whose one API route, GET /api/hello, answers hello world",
|
|
5878
|
-
|
|
6703
|
+
takes: takesOf("hello-world"),
|
|
5879
6704
|
kept: null,
|
|
5880
6705
|
// submit_manifest names no local_run, since the service reads no setting.
|
|
5881
6706
|
steps: (origin, windows) => [
|
|
@@ -5887,14 +6712,11 @@ var TEMPLATE_FACTS = Object.freeze({
|
|
|
5887
6712
|
}),
|
|
5888
6713
|
"database-service": Object.freeze({
|
|
5889
6714
|
is: "a notes service that keeps its data in the platform's database",
|
|
5890
|
-
|
|
5891
|
-
entry: SCAFFOLD_TEMPLATE_ENTRIES["database-service"].entry,
|
|
5892
|
-
follow: "the guide Add a database",
|
|
5893
|
-
page: "/cloud/guides/add-a-database/"
|
|
5894
|
-
}),
|
|
6715
|
+
takes: takesOf("database-service"),
|
|
5895
6716
|
kept: Object.freeze({
|
|
5896
6717
|
resource: Object.freeze({ plural: "notes", singular: "note" }),
|
|
5897
|
-
routes: Object.freeze(["health"])
|
|
6718
|
+
routes: Object.freeze(["health"]),
|
|
6719
|
+
columns: SCAFFOLD_FIELD_RESERVED
|
|
5898
6720
|
}),
|
|
5899
6721
|
// submit_manifest naming local_run, and the provision line that answer
|
|
5900
6722
|
// gives, in the words `provision` prints for a fresh line (provision.ts);
|
|
@@ -5910,15 +6732,7 @@ var TEMPLATE_FACTS = Object.freeze({
|
|
|
5910
6732
|
}),
|
|
5911
6733
|
"scheduled-job": Object.freeze({
|
|
5912
6734
|
is: "the hello-world service with one handler, which the platform runs each hour through the Schedule package",
|
|
5913
|
-
|
|
5914
|
-
entry: SCAFFOLD_TEMPLATE_ENTRIES["scheduled-job"].entry,
|
|
5915
|
-
// The Schedule package's own page: its section What an application
|
|
5916
|
-
// must do says how an application passes its declared handlers to the
|
|
5917
|
-
// package's router, and the page links the guide that declares a
|
|
5918
|
-
// schedule.
|
|
5919
|
-
follow: "the Schedule package's page",
|
|
5920
|
-
page: "/cloud/backend-packages/schedule/"
|
|
5921
|
-
}),
|
|
6735
|
+
takes: takesOf("scheduled-job"),
|
|
5922
6736
|
kept: null,
|
|
5923
6737
|
// hello-world's four, its run step saying no schedule fires on this
|
|
5924
6738
|
// machine, and a fifth: a run fired on the platform, and its readings,
|
|
@@ -5930,15 +6744,40 @@ var TEMPLATE_FACTS = Object.freeze({
|
|
|
5930
6744
|
DEPLOY_STEP,
|
|
5931
6745
|
`Call run_schedule from your tool with the application, the schedule ${SCHEDULED_JOB_SCHEDULE}, and wait_seconds, which fires the handler now and answers how the run ended. A settled answer carries the handler's lines in console, and its more names the read_logs call where the lines were cut, may still be arriving, or could not be read. An answer not yet settled carries no console and names read_schedules, which lists every run.`
|
|
5932
6746
|
]
|
|
6747
|
+
}),
|
|
6748
|
+
"web-app": Object.freeze({
|
|
6749
|
+
is: "a web page with sign-in, each user's own notes in the platform's database, and an AI model under the plan's allowance, in plain HTML and script",
|
|
6750
|
+
takes: takesOf("web-app"),
|
|
6751
|
+
kept: Object.freeze({
|
|
6752
|
+
resource: Object.freeze({ plural: "notes", singular: "note" }),
|
|
6753
|
+
// /api/ask is the template's own route beside the resource's.
|
|
6754
|
+
routes: Object.freeze(["health", "ask"]),
|
|
6755
|
+
// owner holds the id of the signed-in user who added the row.
|
|
6756
|
+
columns: Object.freeze([...SCAFFOLD_FIELD_RESERVED, "owner"])
|
|
6757
|
+
}),
|
|
6758
|
+
// database-service's five, its run step saying where sign-in is and how
|
|
6759
|
+
// a local run's API routes take a session, its shaping step naming the
|
|
6760
|
+
// page first, and a sixth: the page opened on the deployed address.
|
|
6761
|
+
steps: (origin, windows) => [
|
|
6762
|
+
CREATE_STEP,
|
|
6763
|
+
`${PROVISION_AGAIN} The manifest is ${APPLICATION_FOLDER}/${APPLICATION_MANIFEST}, and the line it answers is the provision line, which runs in ${APPLICATION_FOLDER}/.`,
|
|
6764
|
+
`In ${APPLICATION_FOLDER}/, start the application on this machine, where its page then answers at / and its API routes take a session as a Bearer; sign-in is on the deployed application's own address. ${RUN_STEP_BACKGROUND}: ${runLineOf(origin, windows)}`,
|
|
6765
|
+
WEB_SHAPE_STEP,
|
|
6766
|
+
DEPLOY_STEP,
|
|
6767
|
+
WEB_OPEN_STEP
|
|
6768
|
+
]
|
|
5933
6769
|
})
|
|
5934
6770
|
});
|
|
5935
|
-
var
|
|
6771
|
+
var RESOURCE_TEMPLATES = Object.freeze(SCAFFOLD_TEMPLATES.filter((template) => TEMPLATE_FACTS[template].kept !== null));
|
|
5936
6772
|
var keptOf = (template) => {
|
|
5937
6773
|
const kept2 = TEMPLATE_FACTS[template].kept;
|
|
5938
6774
|
if (kept2 === null) throw new Error(`the ${template} template keeps no resource`);
|
|
5939
6775
|
return kept2;
|
|
5940
6776
|
};
|
|
5941
6777
|
var listedAnd = (names) => names.length < 3 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
|
|
6778
|
+
var RESOURCE_TEMPLATES_NAMED = `the ${listedAnd(RESOURCE_TEMPLATES)} template${RESOURCE_TEMPLATES.length === 1 ? "" : "s"}`;
|
|
6779
|
+
var RESOURCE_TEMPLATE_CHOICES = RESOURCE_TEMPLATES.map((template) => `${SCAFFOLD_OPTION.template} ${template}`).join(" or ");
|
|
6780
|
+
var perResourceTemplate = (of) => listedAnd(RESOURCE_TEMPLATES.map((template) => `${orListed(of(keptOf(template)))} under ${template}`));
|
|
5942
6781
|
var orListed = (names) => names.length < 3 ? names.join(" or ") : `${names.slice(0, -1).join(", ")}, or ${names[names.length - 1]}`;
|
|
5943
6782
|
var andListed = (names) => names.length < 3 ? names.join(" and ") : `${names.slice(0, -1).join(", ")}, and ${names[names.length - 1]}`;
|
|
5944
6783
|
var templatesDescribed = () => [
|
|
@@ -5956,7 +6795,7 @@ var SCAFFOLD_OPTIONS = Object.freeze([
|
|
|
5956
6795
|
name: SCAFFOLD_OPTION.resource,
|
|
5957
6796
|
value: "<plural>",
|
|
5958
6797
|
required: false,
|
|
5959
|
-
description: `Under
|
|
6798
|
+
description: `Under ${RESOURCE_TEMPLATES_NAMED}, the resource the service keeps, by its plural, the word of its table and its route: an ASCII lower-case letter, then ASCII lower-case letters and digits, at most 30 characters. It is never the word of the template's own route: ${perResourceTemplate((kept2) => kept2.routes)}. The files are written under its names where the template names notes, note, Notes, and Note, file names among them. Where absent, the service keeps notes. A template that keeps no resource refuses it.`
|
|
5960
6799
|
},
|
|
5961
6800
|
{
|
|
5962
6801
|
name: SCAFFOLD_OPTION.singular,
|
|
@@ -5968,7 +6807,7 @@ var SCAFFOLD_OPTIONS = Object.freeze([
|
|
|
5968
6807
|
name: SCAFFOLD_OPTION.fields,
|
|
5969
6808
|
value: "<list>",
|
|
5970
6809
|
required: false,
|
|
5971
|
-
description: `Under
|
|
6810
|
+
description: `Under ${RESOURCE_TEMPLATES_NAMED}, the resource's columns: fields separated by commas, each a name, a colon, and a kind, such as "name:text,message:text", written between double quotes. A name is an ASCII lower-case letter, then ASCII lower-case letters, digits, and underscores, at most 30 characters. It is never a column the migration writes itself: ${perResourceTemplate((kept2) => kept2.columns)}. A kind is ${orListed(SCAFFOLD_FIELD_KINDS)}. Every column is required, and a list holds one to ${SCAFFOLD_FIELDS_MAX} fields. Where absent, the service keeps one text column, title. A template that keeps no resource refuses it.`
|
|
5972
6811
|
},
|
|
5973
6812
|
{
|
|
5974
6813
|
name: SCAFFOLD_OPTION.path,
|
|
@@ -6019,7 +6858,7 @@ var RESOURCE_REFUSAL = Object.freeze({
|
|
|
6019
6858
|
/** The plural `s` alone, with no `--singular`: its singular would be empty. */
|
|
6020
6859
|
emptySingular: `${SCAFFOLD_OPTION.resource} takes a plural longer than s alone where the line names no ${SCAFFOLD_OPTION.singular}, since the singular, the plural without its closing s, is never empty: name the singular with ${SCAFFOLD_OPTION.singular}, or a longer plural. ${NOTHING_WRITTEN2}`
|
|
6021
6860
|
});
|
|
6022
|
-
var RESOURCE_NONE_KEPT = `${SCAFFOLD_OPTION.resource} and ${SCAFFOLD_OPTION.singular} name the resource
|
|
6861
|
+
var RESOURCE_NONE_KEPT = `${SCAFFOLD_OPTION.resource} and ${SCAFFOLD_OPTION.singular} name the resource ${RESOURCE_TEMPLATES_NAMED} keep, and the template this line writes keeps none: leave them out, or name ${RESOURCE_TEMPLATE_CHOICES}. ${NOTHING_WRITTEN2}`;
|
|
6023
6862
|
var UNKNOWN_TEMPLATE = `${SCAFFOLD_OPTION.template} names the template to write, and the command has no template of that name: it has ${listedAnd(SCAFFOLD_TEMPLATES)}, and it writes ${SCAFFOLD_DEFAULT_TEMPLATE} where the line names none. ${NOTHING_WRITTEN2}`;
|
|
6024
6863
|
var ownRouteRefusal = (word) => `${SCAFFOLD_OPTION.resource} takes a plural other than ${word}, the word of a route the template serves itself: name another plural. ${NOTHING_WRITTEN2}`;
|
|
6025
6864
|
var resourceNamesOf = (call, template) => {
|
|
@@ -6039,6 +6878,8 @@ var resourceNamesOf = (call, template) => {
|
|
|
6039
6878
|
};
|
|
6040
6879
|
var CARRIED_FIELDS = Object.freeze([Object.freeze({ name: "title", kind: "text" })]);
|
|
6041
6880
|
var FIELD_KINDS_CLAUSE = `the kind ${orListed(SCAFFOLD_FIELD_KINDS)}`;
|
|
6881
|
+
var COLUMN_MEANING = Object.freeze({ id: "the key column", owner: "the column of the row's owner" });
|
|
6882
|
+
var reservedFieldRefusal = (columns) => `${SCAFFOLD_OPTION.fields} takes a field name other than ${columns.join(" or ")}, ${listedAnd(columns.map((column) => COLUMN_MEANING[column] ?? `the column ${column}`))} the migration writes itself, and ${FIELD_KINDS_CLAUSE}: name the field another way. ${NOTHING_WRITTEN2}`;
|
|
6042
6883
|
var FIELD_REFUSAL = Object.freeze({
|
|
6043
6884
|
/** A field with no colon or more than one, an empty field among them. */
|
|
6044
6885
|
listForm: `${SCAFFOLD_OPTION.fields} takes fields separated by commas, each a name, a colon, and a kind, such as name:text,message:text, ${FIELD_KINDS_CLAUSE}. ${NOTHING_WRITTEN2}`,
|
|
@@ -6047,17 +6888,18 @@ var FIELD_REFUSAL = Object.freeze({
|
|
|
6047
6888
|
/** A name outside SCAFFOLD_FIELD_NAME_FORM. */
|
|
6048
6889
|
nameForm: `${SCAFFOLD_OPTION.fields} takes each field's name as an ASCII lower-case letter, then ASCII lower-case letters, digits, and underscores, at most 30 characters, such as due_date, and ${FIELD_KINDS_CLAUSE}. ${NOTHING_WRITTEN2}`,
|
|
6049
6890
|
/** A name of SCAFFOLD_FIELD_RESERVED. */
|
|
6050
|
-
reservedName:
|
|
6891
|
+
reservedName: reservedFieldRefusal(SCAFFOLD_FIELD_RESERVED),
|
|
6051
6892
|
/** A kind outside SCAFFOLD_FIELD_KINDS. */
|
|
6052
6893
|
kindUnknown: `${SCAFFOLD_OPTION.fields} takes each field's kind after its name and a colon, ${FIELD_KINDS_CLAUSE}, such as count:integer. ${NOTHING_WRITTEN2}`,
|
|
6053
6894
|
/** Two fields under one name. */
|
|
6054
6895
|
duplicate: `${SCAFFOLD_OPTION.fields} takes each field's name once, and two fields of the list share a name, ${FIELD_KINDS_CLAUSE} after each: name each field once. ${NOTHING_WRITTEN2}`
|
|
6055
6896
|
});
|
|
6056
|
-
var FIELDS_NONE_KEPT = `${SCAFFOLD_OPTION.fields} names the columns of the resource
|
|
6897
|
+
var FIELDS_NONE_KEPT = `${SCAFFOLD_OPTION.fields} names the columns of the resource ${RESOURCE_TEMPLATES_NAMED} keep, and the template this line writes keeps none: leave it out, or name ${RESOURCE_TEMPLATE_CHOICES}. ${NOTHING_WRITTEN2}`;
|
|
6057
6898
|
var fieldsOf = (call, template) => {
|
|
6058
6899
|
const list = call.named[SCAFFOLD_OPTION.fields];
|
|
6059
6900
|
if (list === void 0) return null;
|
|
6060
|
-
|
|
6901
|
+
const { kept: kept2 } = TEMPLATE_FACTS[template];
|
|
6902
|
+
if (kept2 === null) return { refusal: FIELDS_NONE_KEPT };
|
|
6061
6903
|
if (list === "") return { refusal: FIELD_REFUSAL.count };
|
|
6062
6904
|
const items = list.split(",");
|
|
6063
6905
|
if (items.length > SCAFFOLD_FIELDS_MAX) return { refusal: FIELD_REFUSAL.count };
|
|
@@ -6067,7 +6909,7 @@ var fieldsOf = (call, template) => {
|
|
|
6067
6909
|
if (parts.length !== 2) return { refusal: FIELD_REFUSAL.listForm };
|
|
6068
6910
|
const [name, kind] = parts;
|
|
6069
6911
|
if (!SCAFFOLD_FIELD_NAME_FORM.test(name)) return { refusal: FIELD_REFUSAL.nameForm };
|
|
6070
|
-
if (
|
|
6912
|
+
if (kept2.columns.includes(name)) return { refusal: reservedFieldRefusal(kept2.columns) };
|
|
6071
6913
|
const known = SCAFFOLD_FIELD_KINDS.find((held) => held === kind);
|
|
6072
6914
|
if (known === void 0) return { refusal: FIELD_REFUSAL.kindUnknown };
|
|
6073
6915
|
if (fields.some((field) => field.name === name)) return { refusal: FIELD_REFUSAL.duplicate };
|
|
@@ -6121,7 +6963,7 @@ var FIELD_REFUSED_CODES = Object.freeze(["23514", "23502", "22P02", "22003", "22
|
|
|
6121
6963
|
var quotedField = (field) => `"${field.name}"`;
|
|
6122
6964
|
var membersOf = (fields, value) => fields.map((field) => `${field.name}: ${value(field)}`).join(", ");
|
|
6123
6965
|
var objectOf = (members) => members === "" ? "{}" : `{ ${members} }`;
|
|
6124
|
-
var
|
|
6966
|
+
var DATABASE_SERVICE_PLACES = Object.freeze([
|
|
6125
6967
|
{
|
|
6126
6968
|
path: "migrations/0001_notes.sql",
|
|
6127
6969
|
what: "column line",
|
|
@@ -6234,13 +7076,141 @@ var FIELD_PLACES = Object.freeze([
|
|
|
6234
7076
|
}
|
|
6235
7077
|
}
|
|
6236
7078
|
]);
|
|
6237
|
-
var
|
|
6238
|
-
|
|
7079
|
+
var WEB_ROUTE_TESTS = [
|
|
7080
|
+
"test('POST /api/notes keeps a title with characters outside ASCII for the signed-in user, and GET /api/notes returns it to that user alone', async () => {",
|
|
7081
|
+
" const title = 'caf\xE9 \u2615 \u6771\u4EAC';",
|
|
7082
|
+
" const response = await fetch(`${base}/api/notes`, {",
|
|
7083
|
+
" method: 'POST',",
|
|
7084
|
+
" headers: { 'content-type': 'application/json', ...signedIn(sam) },",
|
|
7085
|
+
" body: JSON.stringify({ title }),",
|
|
7086
|
+
" });",
|
|
7087
|
+
" assert.equal(response.status, 201);",
|
|
7088
|
+
" assert.equal((await response.json()).title, title);",
|
|
7089
|
+
" const listed = await fetch(`${base}/api/notes`, { headers: signedIn(sam) });",
|
|
7090
|
+
" assert.equal(listed.status, 200);",
|
|
7091
|
+
" assert.deepEqual((await listed.json()).map((row) => row.title), [title]);",
|
|
7092
|
+
" assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(kim) })).json(), []);",
|
|
7093
|
+
"});",
|
|
7094
|
+
"",
|
|
7095
|
+
"test('POST /api/notes refuses an empty title, and GET /api/notes returns no row', async () => {",
|
|
7096
|
+
" const response = await fetch(`${base}/api/notes`, {",
|
|
7097
|
+
" method: 'POST',",
|
|
7098
|
+
" headers: { 'content-type': 'application/json', ...signedIn(sam) },",
|
|
7099
|
+
" body: JSON.stringify({ title: '' }),",
|
|
7100
|
+
" });",
|
|
7101
|
+
" assert.equal(response.status, 400);",
|
|
7102
|
+
" assert.deepEqual(await response.json(), { error: 'invalid_note' });",
|
|
7103
|
+
" assert.deepEqual(await (await fetch(`${base}/api/notes`, { headers: signedIn(sam) })).json(), []);",
|
|
7104
|
+
"});"
|
|
7105
|
+
].join("\n");
|
|
7106
|
+
var WEB_APP_PLACES = Object.freeze([
|
|
7107
|
+
{
|
|
7108
|
+
path: "client/app.js",
|
|
7109
|
+
what: "field list",
|
|
7110
|
+
carried: "const FIELDS = [{ name: 'title', kind: 'text' }];",
|
|
7111
|
+
render: (fields) => `const FIELDS = [${fields.map((field) => `{ name: '${field.name}', kind: '${field.kind}' }`).join(", ")}];`
|
|
7112
|
+
},
|
|
7113
|
+
{ ...DATABASE_SERVICE_PLACES[0] },
|
|
7114
|
+
{
|
|
7115
|
+
path: "src/db.mjs",
|
|
7116
|
+
what: "add signature",
|
|
7117
|
+
carried: " async addNote(owner, title) {",
|
|
7118
|
+
render: (_fields, forms) => ` async add${forms.Singular}(owner, row) {`
|
|
7119
|
+
},
|
|
7120
|
+
{
|
|
7121
|
+
path: "src/db.mjs",
|
|
7122
|
+
what: "insert",
|
|
7123
|
+
carried: `pool.query('INSERT INTO "notes" (owner, title) VALUES ($1, $2) RETURNING id, title', [owner, title])`,
|
|
7124
|
+
render: (fields, forms) => {
|
|
7125
|
+
const columns = fields.map(quotedField).join(", ");
|
|
7126
|
+
const values = fields.map((_field, index) => `$${index + 2}`).join(", ");
|
|
7127
|
+
return `pool.query('INSERT INTO "${forms.plural}" (owner, ${columns}) VALUES ($1, ${values}) RETURNING id, ${columns}', [owner, ${fields.map((field) => `row.${field.name}`).join(", ")}])`;
|
|
7128
|
+
}
|
|
7129
|
+
},
|
|
7130
|
+
{
|
|
7131
|
+
path: "src/db.mjs",
|
|
7132
|
+
what: "select",
|
|
7133
|
+
carried: `pool.query('SELECT id, title FROM "notes" WHERE owner = $1 ORDER BY id', [owner])`,
|
|
7134
|
+
render: (fields, forms) => `pool.query('SELECT id, ${fields.map(quotedField).join(", ")} FROM "${forms.plural}" WHERE owner = $1 ORDER BY id', [owner])`
|
|
7135
|
+
},
|
|
7136
|
+
{
|
|
7137
|
+
path: "src/server.mjs",
|
|
7138
|
+
what: "add call",
|
|
7139
|
+
carried: "db.addNote(user, String(posted?.title ?? ''))",
|
|
7140
|
+
render: (fields, forms) => `db.add${forms.Singular}(user, ${objectOf(membersOf(fields, (field) => field.kind === "text" ? `String(posted?.${field.name} ?? '')` : `posted?.${field.name} ?? null`))})`
|
|
7141
|
+
},
|
|
7142
|
+
{ ...DATABASE_SERVICE_PLACES[5] },
|
|
7143
|
+
{
|
|
7144
|
+
path: "test/app.test.mjs",
|
|
7145
|
+
what: "sample row",
|
|
7146
|
+
carried: [
|
|
7147
|
+
" assert.deepEqual(await db.addNote('user-sam', 'first'), { id: '1', title: 'first' });",
|
|
7148
|
+
" assert.deepEqual(await db.listNotes('user-sam'), [{ id: '1', title: 'first' }]);"
|
|
7149
|
+
].join("\n"),
|
|
7150
|
+
render: (fields, forms) => {
|
|
7151
|
+
const added = membersOf(fields, (field) => FIELD_SAMPLE[field.kind].added);
|
|
7152
|
+
return [
|
|
7153
|
+
` assert.deepEqual(await db.add${forms.Singular}('user-sam', { ${added} }), { id: '1', ${added} });`,
|
|
7154
|
+
` assert.deepEqual(await db.list${forms.Plural}('user-sam'), [{ id: '1', ${added} }]);`
|
|
7155
|
+
].join("\n");
|
|
7156
|
+
}
|
|
7157
|
+
},
|
|
7158
|
+
{
|
|
7159
|
+
path: "test/app.test.mjs",
|
|
7160
|
+
what: "pair of route tests",
|
|
7161
|
+
carried: WEB_ROUTE_TESTS,
|
|
7162
|
+
render: (fields, forms) => {
|
|
7163
|
+
const route = "`${base}/api/" + forms.plural + "`";
|
|
7164
|
+
const first = fields[0];
|
|
7165
|
+
const kept2 = fields.some((field) => field.kind === "text") ? " with characters outside ASCII in its text" : "";
|
|
7166
|
+
return [
|
|
7167
|
+
`test('POST /api/${forms.plural} keeps a row${kept2} for the signed-in user, and GET /api/${forms.plural} returns it to that user alone', async () => {`,
|
|
7168
|
+
` const row = ${objectOf(membersOf(fields, (field) => FIELD_SAMPLE[field.kind].posted))};`,
|
|
7169
|
+
` const response = await fetch(${route}, {`,
|
|
7170
|
+
" method: 'POST',",
|
|
7171
|
+
" headers: { 'content-type': 'application/json', ...signedIn(sam) },",
|
|
7172
|
+
" body: JSON.stringify(row),",
|
|
7173
|
+
" });",
|
|
7174
|
+
" assert.equal(response.status, 201);",
|
|
7175
|
+
" const { id, ...added } = await response.json();",
|
|
7176
|
+
" assert.deepEqual(added, row);",
|
|
7177
|
+
` const listed = await fetch(${route}, { headers: signedIn(sam) });`,
|
|
7178
|
+
" assert.equal(listed.status, 200);",
|
|
7179
|
+
" assert.deepEqual((await listed.json()).map(({ id, ...kept }) => kept), [row]);",
|
|
7180
|
+
` assert.deepEqual(await (await fetch(${route}, { headers: signedIn(kim) })).json(), []);`,
|
|
7181
|
+
"});",
|
|
7182
|
+
"",
|
|
7183
|
+
`test('POST /api/${forms.plural} refuses a ${forms.singular} without its ${first.name} field, and GET /api/${forms.plural} returns no row', async () => {`,
|
|
7184
|
+
` const response = await fetch(${route}, {`,
|
|
7185
|
+
" method: 'POST',",
|
|
7186
|
+
" headers: { 'content-type': 'application/json', ...signedIn(sam) },",
|
|
7187
|
+
` body: JSON.stringify(${objectOf(membersOf(fields.slice(1), (field) => FIELD_SAMPLE[field.kind].posted))}),`,
|
|
7188
|
+
" });",
|
|
7189
|
+
" assert.equal(response.status, 400);",
|
|
7190
|
+
` assert.deepEqual(await response.json(), { error: 'invalid_${forms.singular}' });`,
|
|
7191
|
+
` assert.deepEqual(await (await fetch(${route}, { headers: signedIn(sam) })).json(), []);`,
|
|
7192
|
+
"});"
|
|
7193
|
+
].join("\n");
|
|
7194
|
+
}
|
|
7195
|
+
}
|
|
7196
|
+
]);
|
|
7197
|
+
var FIELD_PLACES = Object.freeze({
|
|
7198
|
+
"database-service": DATABASE_SERVICE_PLACES,
|
|
7199
|
+
"web-app": WEB_APP_PLACES
|
|
7200
|
+
});
|
|
7201
|
+
var fieldPlacesOf = (template) => {
|
|
7202
|
+
const places = FIELD_PLACES[template];
|
|
7203
|
+
if (places === void 0) throw new Error(`the ${template} template keeps no resource`);
|
|
7204
|
+
return places;
|
|
7205
|
+
};
|
|
7206
|
+
var fieldFilesOf = (files, fields, names, template = "database-service") => {
|
|
7207
|
+
const own = keptOf(template).resource;
|
|
6239
7208
|
const to = names ?? own;
|
|
6240
7209
|
const [plural, singular, Plural, Singular] = resourceFormsOf(to);
|
|
6241
7210
|
const forms = { plural, singular, Plural, Singular };
|
|
7211
|
+
const places = fieldPlacesOf(template);
|
|
6242
7212
|
return files.map(([path, text]) => {
|
|
6243
|
-
const found =
|
|
7213
|
+
const found = places.filter((place) => resourceTextOf(place.path, own, to) === path).map((place) => {
|
|
6244
7214
|
const carried = resourceTextOf(place.carried, own, to);
|
|
6245
7215
|
const around = text.split(carried);
|
|
6246
7216
|
if (around.length !== 2) throw new Error(`the template's ${place.path} does not hold its ${place.what} exactly once`);
|
|
@@ -6259,7 +7229,7 @@ var fieldFilesOf = (files, fields, names) => {
|
|
|
6259
7229
|
var writtenFilesOf = (template, names, fields = null) => {
|
|
6260
7230
|
const files = SCAFFOLD_TEMPLATE_FILES[template];
|
|
6261
7231
|
const named = names === null ? files : resourceFilesOf(files, keptOf(template).resource, names);
|
|
6262
|
-
return fields === null ? named : fieldFilesOf(named, fields, names);
|
|
7232
|
+
return fields === null ? named : fieldFilesOf(named, fields, names, template);
|
|
6263
7233
|
};
|
|
6264
7234
|
var manifestWithPackages = (text, rows) => {
|
|
6265
7235
|
const around = text.split(EMPTY_PACKAGES);
|
|
@@ -6339,14 +7309,21 @@ var standsAt = (io2, folder, name) => {
|
|
|
6339
7309
|
};
|
|
6340
7310
|
var noFolderSentence = (name, where) => `Something that is no folder stands at ${name} in the project's root, where ${where}: move it, then run the line again. ${NOTHING_WRITTEN2}`;
|
|
6341
7311
|
var scaffoldLinkSentence = (what) => `${what} stands on a link that leads outside the project's root, and the scaffold never writes through one. Put a folder in the link's place, or remove it, then run the line again. ${NOTHING_WRITTEN2}`;
|
|
6342
|
-
var
|
|
6343
|
-
|
|
7312
|
+
var listOf = (value) => value === null ? [] : Array.isArray(value) ? value : [value];
|
|
7313
|
+
var applicationStandsLines = (template, takeLines) => {
|
|
7314
|
+
const { takes } = TEMPLATE_FACTS[template];
|
|
7315
|
+
const lines = listOf(takeLines);
|
|
6344
7316
|
const first = `The project's root holds ${APPLICATION_FOLDER}/ with files in it, so an application stands there, and ${SCAFFOLD_SUBCOMMAND} starts a new application and writes over none. ${NOTHING_WRITTEN2}`;
|
|
6345
|
-
if (
|
|
6346
|
-
return [
|
|
7317
|
+
if (takes.length === 0 || lines.length !== takes.length) return [first];
|
|
7318
|
+
return [
|
|
7319
|
+
first,
|
|
7320
|
+
...takes.map(
|
|
7321
|
+
(take, index) => `To add the ${take.entry} package to the application that stands, run this line at the project's root, then follow ${take.follow}, read_documentation's page ${take.page}: ${lines[index]}`
|
|
7322
|
+
)
|
|
7323
|
+
];
|
|
6347
7324
|
};
|
|
6348
7325
|
var STAGE_NOT_OWN = `Something that is not this run's own stands at ${STAGE_FOLDER} in the project's root, the name the scaffold writes under, an earlier run's files under another template or another resource name among it: move or remove ${STAGE_FOLDER}, then run the line again. ${NOTHING_WRITTEN2}`;
|
|
6349
|
-
var folderRefusalOf = (io2, options, files,
|
|
7326
|
+
var folderRefusalOf = (io2, options, files, takeLines) => {
|
|
6350
7327
|
const { root } = options;
|
|
6351
7328
|
for (let at = root; ; at = dirname5(at)) {
|
|
6352
7329
|
const standing2 = io2.stat(at);
|
|
@@ -6361,7 +7338,7 @@ var folderRefusalOf = (io2, options, files, takeLine) => {
|
|
|
6361
7338
|
const notOwn = STAGE_NOT_OWN;
|
|
6362
7339
|
const standing = io2.stat(app);
|
|
6363
7340
|
if (standing === null ? danglingAt(io2, root, APPLICATION_FOLDER) : !standing.folder) return [noFolderSentence(APPLICATION_FOLDER, "the scaffold writes the application")];
|
|
6364
|
-
if (standing !== null && (io2.listFolder(app) ?? []).length > 0) return applicationStandsLines(options.template,
|
|
7341
|
+
if (standing !== null && (io2.listFolder(app) ?? []).length > 0) return applicationStandsLines(options.template, takeLines);
|
|
6365
7342
|
if (!heldInside(io2, root, app)) return [scaffoldLinkSentence(`The application's folder, ${APPLICATION_FOLDER}/,`)];
|
|
6366
7343
|
const staged = io2.stat(stage);
|
|
6367
7344
|
if (staged === null && danglingAt(io2, root, STAGE_FOLDER)) return [notOwn];
|
|
@@ -6372,17 +7349,39 @@ var folderRefusalOf = (io2, options, files, takeLine) => {
|
|
|
6372
7349
|
};
|
|
6373
7350
|
var RUN_AGAIN = "Run the same line again.";
|
|
6374
7351
|
var finishSentence = (entry, takeLine) => `To finish, run this line at the project's root, which takes the ${entry} entry again whole and installs, then run npm test in ${APPLICATION_FOLDER}/: ${takeLine}`;
|
|
7352
|
+
var LINE_COUNT_WORDS = Object.freeze({ 2: "two", 3: "three", 4: "four" });
|
|
7353
|
+
var finishEachSentence = (entries, takeLines) => [
|
|
7354
|
+
`To finish, run each of these ${LINE_COUNT_WORDS[takeLines.length] ?? takeLines.length} lines at the project's root, in order, which take the ${listedAnd(entries)} entries again whole and install, then run npm test in ${APPLICATION_FOLDER}/:`,
|
|
7355
|
+
...takeLines
|
|
7356
|
+
].join("\n");
|
|
6375
7357
|
var FINISH_UNTAKEN = `To finish, run npm install and then npm test in ${APPLICATION_FOLDER}/.`;
|
|
6376
|
-
var finishOf = (entry, takeLine) =>
|
|
6377
|
-
|
|
7358
|
+
var finishOf = (entry, takeLine) => {
|
|
7359
|
+
const [entries, lines] = [listOf(entry), listOf(takeLine)];
|
|
7360
|
+
if (entries.length === 0 || lines.length !== entries.length) return FINISH_UNTAKEN;
|
|
7361
|
+
return entries.length === 1 ? finishSentence(entries[0], lines[0]) : finishEachSentence(entries, lines);
|
|
7362
|
+
};
|
|
7363
|
+
var writtenClause = (entry) => {
|
|
7364
|
+
const entries = listOf(entry);
|
|
7365
|
+
if (entries.length === 0) return "the service's files are written";
|
|
7366
|
+
return entries.length === 1 ? `the service's files, the ${entries[0]} entry, its row, and the copy are written` : `the service's files, the ${listedAnd(entries)} entries, their rows, and the copies are written`;
|
|
7367
|
+
};
|
|
6378
7368
|
var installFailedLine = (entry, takeLine) => `npm install in ${APPLICATION_FOLDER}/ did not finish: ${writtenClause(entry)}. ${finishOf(entry, takeLine)}`;
|
|
6379
7369
|
var servedEntryOf = (entry, version) => version === null ? `${entry}, at no version,` : `${entry} ${shownWhole(version)}`;
|
|
6380
|
-
var
|
|
6381
|
-
|
|
6382
|
-
|
|
7370
|
+
var servedListOf = (served) => ({
|
|
7371
|
+
named: listedAnd(served.map(({ entry, version }) => servedEntryOf(entry, version))),
|
|
7372
|
+
it: served.length === 1 ? "it" : "them"
|
|
7373
|
+
});
|
|
7374
|
+
var takenTestsFailedLine = (served) => {
|
|
7375
|
+
const { named, it } = servedListOf(served);
|
|
7376
|
+
return `npm test failed in ${APPLICATION_FOLDER}/ on the files as written, before any edit, with ${named} as the library served ${it}. The failure is the platform's and not yours: report it, naming ${named}.`;
|
|
6383
7377
|
};
|
|
6384
7378
|
var untakenTestsFailedLine = (template) => `npm test failed in ${APPLICATION_FOLDER}/ on the files as written, before any edit. The failure is the platform's and not yours: report it, naming the ${template} template and ${TURNZERO_CLOUD_PACKAGE} ${commandVersion()}.`;
|
|
6385
|
-
var testedLine = (served) =>
|
|
7379
|
+
var testedLine = (served) => {
|
|
7380
|
+
const entries = listOf(served);
|
|
7381
|
+
if (entries.length === 0) return `${SCAFFOLD_LINE_HEAD.tested}npm test passed in ${APPLICATION_FOLDER}/ on the files as written.`;
|
|
7382
|
+
const { named, it } = servedListOf(entries);
|
|
7383
|
+
return `${SCAFFOLD_LINE_HEAD.tested}npm test passed in ${APPLICATION_FOLDER}/ on the files as written, with ${named} as the library served ${it}.`;
|
|
7384
|
+
};
|
|
6386
7385
|
var notStartedLine = (what, code, entry, takeLine) => `${what} could not be started in ${APPLICATION_FOLDER}/ (${code}): npm stands beside Node.js and must be on the PATH. ${finishOf(entry, takeLine)}`;
|
|
6387
7386
|
var stoppedLine = (what, ended, testsLeft, entry, takeLine) => `${what} in ${APPLICATION_FOLDER}/ was ${ended.interrupted === true ? "interrupted" : `ended by ${ended.signal ?? "a signal"}`}${testsLeft ? ", and the tests were not started" : ""}: ${writtenClause(entry)}. ${finishOf(entry, takeLine)}`;
|
|
6388
7387
|
var errorWordOf = (error) => {
|
|
@@ -6400,36 +7399,39 @@ var applicationStateOf = (io2, app) => {
|
|
|
6400
7399
|
return "unread";
|
|
6401
7400
|
}
|
|
6402
7401
|
};
|
|
6403
|
-
var standsWayOn = (template,
|
|
6404
|
-
const lines = applicationStandsLines(template,
|
|
6405
|
-
return lines.length > 1 ? `. ${lines
|
|
7402
|
+
var standsWayOn = (template, takeLines) => {
|
|
7403
|
+
const lines = applicationStandsLines(template, takeLines);
|
|
7404
|
+
return lines.length > 1 ? `. ${lines.slice(1).join("\n")}` : `: ${SCAFFOLD_SUBCOMMAND} starts a new application and writes over none.`;
|
|
6406
7405
|
};
|
|
6407
|
-
var stoppedCloseOf = (state, template,
|
|
7406
|
+
var stoppedCloseOf = (state, template, takeLines) => {
|
|
6408
7407
|
const before = `before ${APPLICATION_FOLDER}/ was written`;
|
|
6409
7408
|
switch (state) {
|
|
6410
7409
|
case "template":
|
|
6411
|
-
return `after ${APPLICATION_FOLDER}/ was written. ${finishOf(TEMPLATE_FACTS[template].take
|
|
7410
|
+
return `after ${APPLICATION_FOLDER}/ was written. ${finishOf(TEMPLATE_FACTS[template].takes.map((take) => take.entry), takeLines)}`;
|
|
6412
7411
|
case "absent":
|
|
6413
7412
|
return `${before}, and no ${APPLICATION_FOLDER}/ stands. ${RUN_AGAIN}`;
|
|
6414
7413
|
case "empty":
|
|
6415
7414
|
return `${before}, and ${APPLICATION_FOLDER}/ stands empty. ${RUN_AGAIN}`;
|
|
6416
7415
|
case "held":
|
|
6417
|
-
return `${before}, and ${APPLICATION_FOLDER}/ holds files, so an application stands there and the template was not written${standsWayOn(template,
|
|
7416
|
+
return `${before}, and ${APPLICATION_FOLDER}/ holds files, so an application stands there and the template was not written${standsWayOn(template, takeLines)}`;
|
|
6418
7417
|
case "no folder":
|
|
6419
7418
|
return `${before}, and something that is no folder stands at ${APPLICATION_FOLDER}: move it, then run the same line again.`;
|
|
6420
7419
|
case "unread":
|
|
6421
7420
|
return `${before}, and what stands at ${APPLICATION_FOLDER} could not be read: make it readable or move it, then run the same line again.`;
|
|
6422
7421
|
}
|
|
6423
7422
|
};
|
|
6424
|
-
var ownErrorLine = (error, state, template,
|
|
6425
|
-
var signalEndedLine = (state, template,
|
|
7423
|
+
var ownErrorLine = (error, state, template, takeLines) => `The command stopped on an error of its own (${errorWordOf(error)}) ${stoppedCloseOf(state, template, takeLines)}`;
|
|
7424
|
+
var signalEndedLine = (state, template, takeLines) => `A signal ended the command ${stoppedCloseOf(state, template, takeLines)}`;
|
|
6426
7425
|
var ApplicationArrived = class extends Error {
|
|
6427
7426
|
};
|
|
6428
|
-
var arrivedLine = (template,
|
|
7427
|
+
var arrivedLine = (template, takeLines) => `${APPLICATION_FOLDER}/ took a file or a folder while the scaffold ran, so an application stands there and the template was not written${standsWayOn(template, takeLines)}`;
|
|
6429
7428
|
var ARRIVED_NO_FOLDER_LINE = `Something that is no folder came to stand at ${APPLICATION_FOLDER} while the scaffold ran, and the template was not written: move it, then run the same line again.`;
|
|
6430
|
-
var SCAFFOLD_AFTER_OWN_ERROR = `where the project's root holds no ${APPLICATION_FOLDER}/, or an empty one, the application was not written, and the same line run again writes it; where ${APPLICATION_FOLDER}/ holds the service's files, the ${LIBRARY_SUBCOMMAND} take line for the template
|
|
6431
|
-
var
|
|
6432
|
-
|
|
7429
|
+
var SCAFFOLD_AFTER_OWN_ERROR = `where the project's root holds no ${APPLICATION_FOLDER}/, or an empty one, the application was not written, and the same line run again writes it; where ${APPLICATION_FOLDER}/ holds the service's files, the ${LIBRARY_SUBCOMMAND} take line for each entry the template takes, run at the project's root, then npm test in ${APPLICATION_FOLDER}/, finish it where the template takes entries, and npm install and then npm test in ${APPLICATION_FOLDER}/ finish it where it takes none`;
|
|
7430
|
+
var printEach = (io2, text) => {
|
|
7431
|
+
for (const line of text.split("\n")) io2.print(line);
|
|
7432
|
+
};
|
|
7433
|
+
var STEP_COUNT_WORDS = Object.freeze({ 4: "four", 5: "five", 6: "six", 7: "seven", 8: "eight" });
|
|
7434
|
+
var nextStepLines = (origin, windows = LINES_IN_WINDOWS_FORM, left = NEITHER_LEFT, template = "database-service") => {
|
|
6433
7435
|
const steps = [
|
|
6434
7436
|
...left.tools === "stands" ? [ROOT_TOOLS_STEP] : left.tools === "failed" ? [ROOT_TOOLS_FAILED_STEP] : [],
|
|
6435
7437
|
...left.claude ? [ROOT_CLAUDE_STEP] : [],
|
|
@@ -6480,6 +7482,22 @@ var untakenLibraryOf = (io2, root, over) => {
|
|
|
6480
7482
|
return { library: found.folder, fresh: found.fresh, packages: heldPackagesOf(manifest.rows) };
|
|
6481
7483
|
};
|
|
6482
7484
|
var LOOP_TURN_MS = 0;
|
|
7485
|
+
var takenPackagesOf = (checks) => {
|
|
7486
|
+
let rows = checks[0]?.rows ?? null;
|
|
7487
|
+
let text = "";
|
|
7488
|
+
for (const { entry } of checks) {
|
|
7489
|
+
text = manifestTextOf(rows, { name: entry.name, version: entry.version, closure_hash: entry.closure_hash, source_commit: entry.source_commit, fetched_at: "" });
|
|
7490
|
+
rows = JSON.parse(text).entries;
|
|
7491
|
+
}
|
|
7492
|
+
return checks.length === 0 ? [] : packagesOf(text);
|
|
7493
|
+
};
|
|
7494
|
+
var writeTakes = (io2, root, checks) => {
|
|
7495
|
+
let written = null;
|
|
7496
|
+
for (const check2 of checks) {
|
|
7497
|
+
const reading = written === null ? check2 : { ...check2, rows: JSON.parse(written).entries, manifestStood: Buffer.from(written, "utf8") };
|
|
7498
|
+
written = writeTake(io2, root, reading);
|
|
7499
|
+
}
|
|
7500
|
+
};
|
|
6483
7501
|
async function scaffold(call, io2) {
|
|
6484
7502
|
const minute = runMinuteOf(io2.clock());
|
|
6485
7503
|
const options = scaffoldOptionsOf(call, io2.cwd);
|
|
@@ -6491,12 +7509,12 @@ async function scaffold(call, io2) {
|
|
|
6491
7509
|
const agent = userAgentOf(io2.env);
|
|
6492
7510
|
if ("refusal" in agent) return refuse(agent.refusal);
|
|
6493
7511
|
const { root, origin, template } = options;
|
|
6494
|
-
const {
|
|
7512
|
+
const { takes } = TEMPLATE_FACTS[template];
|
|
6495
7513
|
const defaulted = call.named[SCAFFOLD_OPTION.template] === void 0;
|
|
6496
7514
|
const files = writtenFilesOf(template, options.names, options.fields);
|
|
6497
7515
|
const app = join8(root, APPLICATION_FOLDER);
|
|
6498
7516
|
const stage = join8(root, STAGE_FOLDER);
|
|
6499
|
-
const
|
|
7517
|
+
const takeLines = takes.map((take) => machineLineOf(libraryTakeLineOf({ origin, entry: take.entry }), io2.windows ?? LINES_IN_WINDOWS_FORM));
|
|
6500
7518
|
let reached = "checks";
|
|
6501
7519
|
const stateNow = () => {
|
|
6502
7520
|
if (reached === "template") return "template";
|
|
@@ -6514,33 +7532,35 @@ async function scaffold(call, io2) {
|
|
|
6514
7532
|
};
|
|
6515
7533
|
let release = () => void 0;
|
|
6516
7534
|
try {
|
|
6517
|
-
const refused = folderRefusalOf(io2, options, files,
|
|
7535
|
+
const refused = folderRefusalOf(io2, options, files, takeLines);
|
|
6518
7536
|
if (refused !== null) return refuse(...refused);
|
|
6519
|
-
|
|
7537
|
+
const checks = [];
|
|
6520
7538
|
let held;
|
|
6521
|
-
if (
|
|
7539
|
+
if (takes.length === 0) {
|
|
6522
7540
|
const found = untakenLibraryOf(io2, root, (library) => writesOver(library, app));
|
|
6523
7541
|
if ("lines" in found) return refuse(...found.lines);
|
|
6524
7542
|
held = found;
|
|
6525
7543
|
} else {
|
|
6526
|
-
const
|
|
6527
|
-
|
|
6528
|
-
|
|
6529
|
-
|
|
6530
|
-
|
|
6531
|
-
|
|
7544
|
+
for (const take of takes) {
|
|
7545
|
+
const copy = join8(app, "lib", take.entry);
|
|
7546
|
+
const planOf = (library) => writesOver(library, copy) ? { refusal: LIBRARY_OVER_APPLICATION } : { application: app, copy, declared: null, declaredAt: null };
|
|
7547
|
+
const read2 = await readTake(io2, { entry: take.entry, root, named: options.named, origin }, agent.userAgent, planOf, checks.length > 0);
|
|
7548
|
+
if ("lines" in read2) return refuse(...read2.lines);
|
|
7549
|
+
if (read2.plan === null) {
|
|
7550
|
+
return refuse(`The ${take.entry} entry the library served carries no compiled modules, which the service's package file declares and installs. That is the platform's to fix: report it. ${NOTHING_WRITTEN2}`);
|
|
7551
|
+
}
|
|
7552
|
+
checks.push(read2);
|
|
6532
7553
|
}
|
|
6533
|
-
|
|
6534
|
-
held = { library: read2.library, fresh: read2.fresh, packages: read2.packages };
|
|
7554
|
+
held = { library: checks[0].library, fresh: checks[0].fresh, packages: takenPackagesOf(checks) };
|
|
6535
7555
|
}
|
|
6536
|
-
const arrived = folderRefusalOf(io2, options, files,
|
|
7556
|
+
const arrived = folderRefusalOf(io2, options, files, takeLines);
|
|
6537
7557
|
if (arrived !== null) return refuse(...arrived);
|
|
6538
7558
|
const toolsStands = standsAt(io2, root, TOOLS_FILE);
|
|
6539
7559
|
const claudeStands = standsAt(io2, root, CLAUDE_FILE);
|
|
6540
7560
|
let ended = false;
|
|
6541
7561
|
release = io2.whenEnded(() => {
|
|
6542
7562
|
ended = true;
|
|
6543
|
-
io2
|
|
7563
|
+
printEach(io2, signalEndedLine(stateNow(), template, takeLines));
|
|
6544
7564
|
});
|
|
6545
7565
|
reached = "writes";
|
|
6546
7566
|
if (io2.stat(root) === null) io2.makeFolder(root);
|
|
@@ -6552,24 +7572,24 @@ async function scaffold(call, io2) {
|
|
|
6552
7572
|
for (const [path] of files) io2.print(`${SCAFFOLD_LINE_HEAD.written}${APPLICATION_FOLDER}/${path}`);
|
|
6553
7573
|
io2.print(tools === "written" ? `${SCAFFOLD_LINE_HEAD.written}${TOOLS_FILE}` : tools === "stands" ? ROOT_TOOLS_UNCHANGED : ROOT_TOOLS_FAILED);
|
|
6554
7574
|
if (claudeStands) io2.print(ROOT_CLAUDE_UNCHANGED);
|
|
6555
|
-
|
|
7575
|
+
writeTakes(io2, root, checks);
|
|
6556
7576
|
await io2.sleep(LOOP_TURN_MS);
|
|
6557
7577
|
release();
|
|
6558
7578
|
if (ended) return COMMAND_STATUS.unknown;
|
|
6559
|
-
const taken =
|
|
7579
|
+
const taken = checks.map((check2, index) => ({ entry: takes[index].entry, version: check2.entry.version, line: takeLines[index] }));
|
|
6560
7580
|
return await installAndTest(io2, app, template, taken, origin, { tools, claude: claudeStands });
|
|
6561
7581
|
} catch (error) {
|
|
6562
7582
|
const state = stateNow();
|
|
6563
7583
|
const arrivedAs = error instanceof ApplicationArrived ? state : null;
|
|
6564
|
-
io2
|
|
7584
|
+
printEach(io2, arrivedAs === "held" ? arrivedLine(template, takeLines) : arrivedAs === "no folder" ? ARRIVED_NO_FOLDER_LINE : ownErrorLine(error, state, template, takeLines));
|
|
6565
7585
|
return COMMAND_STATUS.unknown;
|
|
6566
7586
|
} finally {
|
|
6567
7587
|
release();
|
|
6568
7588
|
}
|
|
6569
7589
|
}
|
|
6570
7590
|
async function installAndTest(io2, app, template, taken, origin, left) {
|
|
6571
|
-
const
|
|
6572
|
-
const
|
|
7591
|
+
const entries = taken.map(({ entry }) => entry);
|
|
7592
|
+
const takeLines = taken.map(({ line }) => line);
|
|
6573
7593
|
const env = installEnvironmentOf(io2.env);
|
|
6574
7594
|
const printError = io2.printError ?? io2.print;
|
|
6575
7595
|
const npmEntry = NPM_NEEDS_SHELL ? npmEntryOf(io2, process.execPath) : null;
|
|
@@ -6583,24 +7603,24 @@ async function installAndTest(io2, app, template, taken, origin, left) {
|
|
|
6583
7603
|
const stopped = (ended) => ended.interrupted === true || ended.error === null && ended.status === null;
|
|
6584
7604
|
const stop = (what, ended, testsLeft) => {
|
|
6585
7605
|
printAfterInterrupt(io2, afterInterruptedChildLineOf(ended, what, io2.windows ?? NPM_NEEDS_SHELL));
|
|
6586
|
-
io2
|
|
7606
|
+
printEach(io2, stoppedLine(what, ended, testsLeft, entries, takeLines));
|
|
6587
7607
|
return COMMAND_STATUS.unknown;
|
|
6588
7608
|
};
|
|
6589
7609
|
const installed = await child(INSTALL_ARGS);
|
|
6590
7610
|
if (stopped(installed)) return stop("npm install", installed, true);
|
|
6591
7611
|
if (installed.error !== null || installed.status !== 0) {
|
|
6592
|
-
io2
|
|
7612
|
+
printEach(io2, installFailedLine(entries, takeLines));
|
|
6593
7613
|
return COMMAND_STATUS.failed;
|
|
6594
7614
|
}
|
|
6595
7615
|
io2.print(`${SCAFFOLD_LINE_HEAD.installed}npm install ran in ${APPLICATION_FOLDER}/.`);
|
|
6596
7616
|
const tested = await child(TEST_ARGS);
|
|
6597
7617
|
if (stopped(tested)) return stop("npm test", tested, false);
|
|
6598
7618
|
if (tested.error !== null) {
|
|
6599
|
-
io2
|
|
7619
|
+
printEach(io2, notStartedLine("npm test", tested.error, entries, takeLines));
|
|
6600
7620
|
return COMMAND_STATUS.failed;
|
|
6601
7621
|
}
|
|
6602
7622
|
if (tested.status !== 0) {
|
|
6603
|
-
io2.print(taken ===
|
|
7623
|
+
io2.print(taken.length === 0 ? untakenTestsFailedLine(template) : takenTestsFailedLine(taken));
|
|
6604
7624
|
return COMMAND_STATUS.failed;
|
|
6605
7625
|
}
|
|
6606
7626
|
io2.print(testedLine(taken));
|
|
@@ -6609,21 +7629,21 @@ async function installAndTest(io2, app, template, taken, origin, left) {
|
|
|
6609
7629
|
}
|
|
6610
7630
|
var SCAFFOLD_ROW = Object.freeze({
|
|
6611
7631
|
name: SCAFFOLD_SUBCOMMAND,
|
|
6612
|
-
summary: `Writes a new service from a template into a project, ${SCAFFOLD_DEFAULT_TEMPLATE} where the line names no ${SCAFFOLD_OPTION.template}. A template that takes
|
|
7632
|
+
summary: `Writes a new service from a template into a project, ${SCAFFOLD_DEFAULT_TEMPLATE} where the line names no ${SCAFFOLD_OPTION.template}. A template that takes library entries, as database-service takes the Database entry and web-app takes three, takes each as library take does, checking each file's SHA-256 and each entry's closure hash before it writes anything. It then writes the template's files under app/, the manifest's packages member filled from the library folder's rows, runs npm install and npm test there, and prints the next steps. It starts a new application and writes over none. It reads no credential.`,
|
|
6613
7633
|
options: SCAFFOLD_OPTIONS,
|
|
6614
7634
|
credential: null,
|
|
6615
7635
|
statuses: {
|
|
6616
|
-
done: "the files written and installed, the
|
|
7636
|
+
done: "the files written and installed, the entries taken where the template takes any, and the tests passed",
|
|
6617
7637
|
failed: "the files written, and npm install or the tests failed",
|
|
6618
7638
|
unknown: "the run stopped before its end, by an interrupt or another signal that ended npm, by an error of the command's own, or by something that came to stand at app while it wrote: the printed sentence names the way on",
|
|
6619
7639
|
nothing_started: "nothing was written"
|
|
6620
7640
|
},
|
|
6621
7641
|
note: [
|
|
6622
7642
|
`Run the ${SCAFFOLD_SUBCOMMAND} line at the root of a new project, or name the root with ${SCAFFOLD_OPTION.path}, which the command makes where it does not stand. A line that names no ${SCAFFOLD_OPTION.template} writes ${SCAFFOLD_DEFAULT_TEMPLATE}, which takes no library entry and makes no request, and says so in a line before the files it wrote. Under scheduled-job, the minute of the schedule's cron in the manifest is the minute of the hour, in UTC, at which the run started.`,
|
|
6623
|
-
`Under ${
|
|
7643
|
+
`Under ${listedAnd(RESOURCE_TEMPLATES)}, with ${SCAFFOLD_OPTION.resource}, and ${SCAFFOLD_OPTION.singular} where the singular is not the plural without its closing s, it writes the same service under the resource's names, its table, its route, its functions, and its file names among them, and runs the tests on the files as written. It refuses, writing nothing, where the root's ${APPLICATION_FOLDER}/ holds any file or folder, and it takes no option that overwrites: where the template takes entries, its sentence names the ${LIBRARY_SUBCOMMAND} take line for each, which adds the package to an application that stands.`,
|
|
6624
7644
|
`A root that holds neither ${LIBRARY_FOLDER}/ nor ${POINTER_FILE} takes a ${LIBRARY_FOLDER}/ of its own. Where the root holds no ${TOOLS_FILE}, it writes one of one sentence, which sends a coding tool to ${APPLICATION_FOLDER}/${TOOLS_FILE}; it changes no file that stands at the root, and where an ${TOOLS_FILE} or a ${CLAUDE_FILE} stands there it prints the sentence to add.`,
|
|
6625
7645
|
`${SCAFFOLD_SUBCOMMAND} leaves ${COMMAND_CREDENTIAL_VARIABLES.join(" and ")} out of what npm install and npm test receive. An interrupt reaches npm, the command ends after it, and after an interrupted install the tests are not started. A signal that arrives after the command's first write and before npm starts is acted on once the writes are made: the command prints the way on, starts no npm, and ends with 128 and the signal's number.`,
|
|
6626
|
-
`Where the tests fail, they failed on the files as written, and the printed line says to report it, naming the
|
|
7646
|
+
`Where the tests fail, they failed on the files as written, and the printed line says to report it, naming the versions the library served where the template takes entries, and the template and the command's version where it takes none.`
|
|
6627
7647
|
],
|
|
6628
7648
|
afterOwnError: SCAFFOLD_AFTER_OWN_ERROR,
|
|
6629
7649
|
run: scaffold
|
|
@@ -6692,7 +7712,7 @@ var TOKEN_ROW = Object.freeze({
|
|
|
6692
7712
|
done: "the challenge printed, or the value printed once",
|
|
6693
7713
|
failed: "the token minted was an authorized computer's, and is revoked where it could be: the printed lines name revoke_token",
|
|
6694
7714
|
unknown: "the exchange drew no answer, or an answer that is not a refusal: list_tokens shows whether a token was minted",
|
|
6695
|
-
nothing_started: "nothing was minted: an option, the credential file, the token code, or a standard error that is no terminal was refused, no request was pending, or the exchange was refused"
|
|
7715
|
+
nothing_started: "nothing was minted: an option, the credential file, the token code, or a standard error that is no terminal was refused, no request was pending, the platform was too old, or the exchange was refused"
|
|
6696
7716
|
},
|
|
6697
7717
|
variables: [[ORIGIN_VARIABLE, AUTHORIZATION_ORIGIN_VARIABLE_LINE]],
|
|
6698
7718
|
note: [`${TOKEN_SUBCOMMAND} ${TOKEN_ACTS[0]} may run in a tool, since it prints nothing secret. ${TOKEN_SUBCOMMAND} ${TOKEN_ACTS[1]} is yours, run in a terminal outside the AI tool on the same computer as the same user, and never by a tool: it runs only where its standard error is a terminal, which yours stays when you pipe its standard output into your secret store's command, such as gh secret set ${MINTED_TOKEN_VARIABLE}. Read the line's origin before you run it.`],
|
|
@@ -7024,7 +8044,7 @@ var usageText = (version = commandVersion()) => {
|
|
|
7024
8044
|
if (unattended.length > 0) {
|
|
7025
8045
|
lines.push(
|
|
7026
8046
|
` ${MINTED_TOKEN_VARIABLE} under ${listed(unattended)}, a minted token for a tool connected by a minted token or an unattended run, set for the one line from where the tool keeps it or from your CI's secret store. Where it is set, it wins over this computer's credential file.`,
|
|
7027
|
-
` ${ORIGIN_VARIABLE} under ${listed(unattended)}, in an unattended run, the platform's address the minted token is sent to where the line names no ${DEPLOY_OPTION.origin}. Under the token, a ${DEPLOY_OPTION.origin} the line names must be that address or ${DEFAULT_COMMAND_ORIGIN}
|
|
8047
|
+
` ${ORIGIN_VARIABLE} under ${listed(unattended)}, in an unattended run, the platform's address the minted token is sent to where the line names no ${DEPLOY_OPTION.origin}. Under the token, a ${DEPLOY_OPTION.origin} the line names must be that address, or ${DEFAULT_COMMAND_ORIGIN} where this variable is unset. Where ${MINTED_TOKEN_VARIABLE} is not set and the line names no ${DEPLOY_OPTION.origin}, this variable names the address whose token the command reads from this computer's credential file${computerOnly.length > 0 ? `, under ${listed(computerOnly)} too where no grant is piped` : ""}. A run under a grant does not read it.`
|
|
7028
8048
|
);
|
|
7029
8049
|
}
|
|
7030
8050
|
lines.push(` ${USER_AGENT_VARIABLE} text appended to the command's user agent on each request.`, "", "Statuses:");
|
|
@@ -7114,34 +8134,36 @@ var nodeMajorOf = (version) => {
|
|
|
7114
8134
|
const major = /^v?(\d+)(?:\.|$)/.exec(version);
|
|
7115
8135
|
return major === null ? null : Number(major[1]);
|
|
7116
8136
|
};
|
|
7117
|
-
var nodeFloorRefusal = (version) => {
|
|
7118
|
-
const major = nodeMajorOf(
|
|
7119
|
-
return major !== null && major < COMMAND_NODE_FLOOR ? nodeFloorSentence(version) : null;
|
|
8137
|
+
var nodeFloorRefusal = (found, version = commandVersion()) => {
|
|
8138
|
+
const major = nodeMajorOf(found);
|
|
8139
|
+
return major !== null && major < COMMAND_NODE_FLOOR ? nodeFloorSentence(version, found) : null;
|
|
7120
8140
|
};
|
|
8141
|
+
var versionLineOf = (version, found) => `${TURNZERO_CLOUD_PACKAGE} ${version} (Node.js ${found})`;
|
|
7121
8142
|
async function runGuarded(argv2, io2) {
|
|
7122
8143
|
const found = io2.nodeVersion ?? process.versions.node;
|
|
7123
|
-
const
|
|
8144
|
+
const version = commandVersion();
|
|
8145
|
+
const belowFloor = nodeFloorRefusal(found, version);
|
|
7124
8146
|
if (belowFloor !== null) {
|
|
7125
|
-
outputOf(argv2, io2)(carriesTokenCode(argv2) ? tokenCodeFloorSentence(found) : belowFloor);
|
|
8147
|
+
outputOf(argv2, io2)(carriesTokenCode(argv2) ? tokenCodeFloorSentence(version, found) : belowFloor);
|
|
7126
8148
|
return COMMAND_STATUS.nothing_started;
|
|
7127
8149
|
}
|
|
7128
8150
|
const parsed = parseArguments(argv2, io2.windows ?? ON_WINDOWS);
|
|
7129
|
-
|
|
7130
|
-
|
|
7131
|
-
|
|
7132
|
-
|
|
7133
|
-
|
|
7134
|
-
|
|
7135
|
-
|
|
7136
|
-
|
|
7137
|
-
|
|
7138
|
-
|
|
7139
|
-
|
|
7140
|
-
|
|
7141
|
-
}
|
|
7142
|
-
|
|
7143
|
-
return parsed.subcommand.run(parsed.call, io2);
|
|
8151
|
+
if (parsed.kind === "help") {
|
|
8152
|
+
io2.print(usageText(version));
|
|
8153
|
+
return COMMAND_STATUS.done;
|
|
8154
|
+
}
|
|
8155
|
+
if (parsed.kind === "version") {
|
|
8156
|
+
io2.print(version);
|
|
8157
|
+
return COMMAND_STATUS.done;
|
|
8158
|
+
}
|
|
8159
|
+
const say = outputOf(argv2, io2);
|
|
8160
|
+
say(versionLineOf(version, found));
|
|
8161
|
+
if (parsed.kind === "refused") {
|
|
8162
|
+
say(carriesTokenCode(argv2) ? `${parsed.sentence} ${TOKEN_CODE_UNREAD}` : parsed.sentence);
|
|
8163
|
+
say(`${SERVED} --help prints the usage.`);
|
|
8164
|
+
return COMMAND_STATUS.nothing_started;
|
|
7144
8165
|
}
|
|
8166
|
+
return parsed.subcommand.run(parsed.call, io2);
|
|
7145
8167
|
}
|
|
7146
8168
|
var outputOf = (argv2, io2) => argv2[0] === TOKEN_SUBCOMMAND && argv2[1] === TOKEN_CLAIM_ACT ? io2.printError ?? io2.print : io2.print;
|
|
7147
8169
|
var NOTHING_REACHED = "no subcommand was reached, so nothing was started";
|