turnzero 0.12.1 → 0.13.0-next.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (2) hide show
  1. package/dist/turnzero-cloud.mjs +1149 -127
  2. package/package.json +6 -1
@@ -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) => `The command 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.`;
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": null,
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 TOKEN_CODE_UNREAD = "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. Run the line again once the cause above is put right.";
760
- var tokenCodeFloorSentence = (found) => `The command 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.`;
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 ${DEFAULT_COMMAND_ORIGIN} or to the address ${ORIGIN_VARIABLE} holds. 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.`
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 preparing call answered no usable upload, the zip holds no file at the health path, the upload or the start was refused, or the upload drew no answer"
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 ${DEFAULT_COMMAND_ORIGIN} or to the address ${ORIGIN_VARIABLE} holds, an address of this machine among them only where the variable names it: under the token, ${DEPLOY_OPTION.origin} must name one of those, and where the line names none the command addresses ${ORIGIN_VARIABLE}'s address. ${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.`,
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 ${DEFAULT_COMMAND_ORIGIN} or to the address ${ORIGIN_VARIABLE} holds. 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.`
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 was answered by a failure"
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 its first 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.`,
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
- 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."
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) => SCAFFOLD_TEMPLATES.find((template) => SCAFFOLD_TEMPLATE_ENTRIES[template]?.entry === name) ?? null;
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 ? null : SCAFFOLD_TEMPLATE_ENTRIES[template];
4526
- if (template === null || taking === null) return OTHER_WAY_ON;
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
- take: null,
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
- take: Object.freeze({
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
- take: Object.freeze({
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 RESOURCE_TEMPLATE = "database-service";
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 the ${RESOURCE_TEMPLATE} template, 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, and never ${keptOf(RESOURCE_TEMPLATE).routes.join(" or ")}. The files are written under its names where the template names notes, note, Notes, and Note, two file names among them. Where absent, the service keeps notes. A template that keeps no resource refuses it.`
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 the ${RESOURCE_TEMPLATE} template, 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, and never id. 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.`
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 the ${RESOURCE_TEMPLATE} template keeps, and the template this line writes keeps none: leave them out, or name ${SCAFFOLD_OPTION.template} ${RESOURCE_TEMPLATE}. ${NOTHING_WRITTEN2}`;
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: `${SCAFFOLD_OPTION.fields} takes a field name other than ${SCAFFOLD_FIELD_RESERVED.join(" or ")}, the key column the migration writes itself, and ${FIELD_KINDS_CLAUSE}: name the field another way. ${NOTHING_WRITTEN2}`,
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 the ${RESOURCE_TEMPLATE} template keeps, and the template this line writes keeps none: leave it out, or name ${SCAFFOLD_OPTION.template} ${RESOURCE_TEMPLATE}. ${NOTHING_WRITTEN2}`;
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
- if (TEMPLATE_FACTS[template].kept === null) return { refusal: FIELDS_NONE_KEPT };
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 (SCAFFOLD_FIELD_RESERVED.includes(name)) return { refusal: FIELD_REFUSAL.reservedName };
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 FIELD_PLACES = Object.freeze([
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 fieldFilesOf = (files, fields, names) => {
6238
- const own = keptOf(RESOURCE_TEMPLATE).resource;
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 = FIELD_PLACES.filter((place) => resourceTextOf(place.path, own, to) === path).map((place) => {
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 applicationStandsLines = (template, takeLine) => {
6343
- const { take } = TEMPLATE_FACTS[template];
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 (take === null || takeLine === null) return [first];
6346
- return [first, `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}: ${takeLine}`];
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, takeLine) => {
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, takeLine);
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) => entry === null || takeLine === null ? FINISH_UNTAKEN : finishSentence(entry, takeLine);
6377
- var writtenClause = (entry) => entry === null ? "the service's files are written" : `the service's files, the ${entry} entry, its row, and the copy are written`;
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 testsFailedLine = (entry, version) => {
6381
- const served = servedEntryOf(entry, version);
6382
- return `npm test failed in ${APPLICATION_FOLDER}/ on the files as written, before any edit, with ${served} as the library served it. The failure is the platform's and not yours: report it, naming ${served}.`;
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) => `${SCAFFOLD_LINE_HEAD.tested}npm test passed in ${APPLICATION_FOLDER}/ on the files as written${served === null ? "" : `, with ${servedEntryOf(served.entry, served.version)} as the library served it`}.`;
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, takeLine) => {
6404
- const lines = applicationStandsLines(template, takeLine);
6405
- return lines.length > 1 ? `. ${lines[1]}` : `: ${SCAFFOLD_SUBCOMMAND} starts a new application and writes over none.`;
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, takeLine) => {
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?.entry ?? null, takeLine)}`;
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, takeLine)}`;
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, takeLine) => `The command stopped on an error of its own (${errorWordOf(error)}) ${stoppedCloseOf(state, template, takeLine)}`;
6425
- var signalEndedLine = (state, template, takeLine) => `A signal ended the command ${stoppedCloseOf(state, template, takeLine)}`;
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, takeLine) => `${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, takeLine)}`;
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's entry, run at the project's root, then npm test in ${APPLICATION_FOLDER}/, finish it where the template takes an entry, and npm install and then npm test in ${APPLICATION_FOLDER}/ finish it where it takes none`;
6431
- var STEP_COUNT_WORDS = Object.freeze({ 4: "four", 5: "five", 6: "six", 7: "seven" });
6432
- var nextStepLines = (origin, windows = LINES_IN_WINDOWS_FORM, left = NEITHER_LEFT, template = RESOURCE_TEMPLATE) => {
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 { take } = TEMPLATE_FACTS[template];
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 takeLine = take === null ? null : machineLineOf(libraryTakeLineOf({ origin, entry: take.entry }), io2.windows ?? LINES_IN_WINDOWS_FORM);
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, takeLine);
7535
+ const refused = folderRefusalOf(io2, options, files, takeLines);
6518
7536
  if (refused !== null) return refuse(...refused);
6519
- let checked = null;
7537
+ const checks = [];
6520
7538
  let held;
6521
- if (take === null) {
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 copy = join8(app, "lib", take.entry);
6527
- const planOf = (library) => writesOver(library, copy) ? { refusal: LIBRARY_OVER_APPLICATION } : { application: app, copy, declared: null, declaredAt: null };
6528
- const read2 = await readTake(io2, { entry: take.entry, root, named: options.named, origin }, agent.userAgent, planOf);
6529
- if ("lines" in read2) return refuse(...read2.lines);
6530
- if (read2.plan === null) {
6531
- 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}`);
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
- checked = read2;
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, takeLine);
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.print(signalEndedLine(stateNow(), template, takeLine));
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
- if (checked !== null) writeTake(io2, root, checked);
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 = take === null || checked === null || takeLine === null ? null : { entry: take.entry, version: checked.entry.version, line: takeLine };
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.print(arrivedAs === "held" ? arrivedLine(template, takeLine) : arrivedAs === "no folder" ? ARRIVED_NO_FOLDER_LINE : ownErrorLine(error, state, template, takeLine));
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 entry = taken?.entry ?? null;
6572
- const takeLine = taken?.line ?? null;
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.print(stoppedLine(what, ended, testsLeft, entry, takeLine));
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.print(installFailedLine(entry, takeLine));
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.print(notStartedLine("npm test", tested.error, entry, takeLine));
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 === null ? untakenTestsFailedLine(template) : testsFailedLine(taken.entry, taken.version));
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 a library entry, as ${RESOURCE_TEMPLATE} takes the Database entry, takes it as library take does, checking each file's SHA-256 and the 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.`,
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 entry taken where the template takes one, and the tests passed",
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 ${RESOURCE_TEMPLATE}, 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 two 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 an entry, its sentence names the ${LIBRARY_SUBCOMMAND} take line, which adds the package to an application that stands.`,
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 version the library served where the template takes an entry, and the template and the command's version where it takes none.`
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}; an address of this machine is admitted only where this variable names it. 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.`
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(version);
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 belowFloor = nodeFloorRefusal(found);
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
- switch (parsed.kind) {
7130
- case "help":
7131
- io2.print(usageText());
7132
- return COMMAND_STATUS.done;
7133
- case "version":
7134
- io2.print(commandVersion());
7135
- return COMMAND_STATUS.done;
7136
- case "refused": {
7137
- const say = outputOf(argv2, io2);
7138
- say(carriesTokenCode(argv2) ? `${parsed.sentence} ${TOKEN_CODE_UNREAD}` : parsed.sentence);
7139
- say(`${SERVED} --help prints the usage.`);
7140
- return COMMAND_STATUS.nothing_started;
7141
- }
7142
- case "run":
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";