@oneteam-app/apps-cli 0.1.0-alpha.6 → 0.1.0-alpha.7

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.js CHANGED
@@ -1,16 +1,565 @@
1
1
  #!/usr/bin/env node
2
2
 
3
3
  // src/index.ts
4
- import { parseArgs } from "util";
4
+ import { parseArgs as parseArgs3 } from "util";
5
5
 
6
6
  // src/commands.ts
7
- import { existsSync, lstatSync, mkdirSync, readFileSync, realpathSync, writeFileSync } from "fs";
8
- import { basename, dirname, join, relative, sep } from "path";
9
- import { validateManifest } from "@oneteam-app/apps/contracts";
7
+ import { existsSync as existsSync4, lstatSync as lstatSync2, mkdirSync, readFileSync as readFileSync4, realpathSync, writeFileSync } from "fs";
8
+ import { basename, dirname, join as join4, relative as relative2, sep as sep2 } from "path";
9
+ import { isHostedManifest, SECRET_NAME, validateManifest } from "@oneteam-app/apps/contracts";
10
+
11
+ // src/hosted/bundle.ts
12
+ import { execFileSync, spawnSync } from "child_process";
13
+ import { existsSync, lstatSync, readdirSync, readFileSync } from "fs";
14
+ import { join, relative, sep } from "path";
15
+ import { BUNDLE_LAYOUT, MAX_BUNDLE_BYTES } from "@oneteam-app/apps/contracts";
16
+
17
+ // src/hosted/scan.ts
18
+ var ENV_FILE = /^(?:\.env|\.dev\.vars)(?:\.[^/]+)?$/;
19
+ var ENV_EXAMPLE = /\.(?:example|sample|template|dist)$/;
20
+ function isEnvFile(path) {
21
+ const name = path.split("/").pop() ?? "";
22
+ return ENV_FILE.test(name) && !ENV_EXAMPLE.test(name);
23
+ }
24
+ var KNOWN_SHAPES = [
25
+ { kind: "a private key", pattern: /-----BEGIN (?:[A-Z]+ )?PRIVATE KEY-----/g },
26
+ { kind: "an AWS access key", pattern: /\b(?:AKIA|ASIA)[0-9A-Z]{16}\b/g },
27
+ { kind: "a Stripe secret key", pattern: /\b(?:sk|rk)_live_[0-9A-Za-z]{16,}/g },
28
+ { kind: "a GitHub token", pattern: /\b(?:gh[pousr]_[A-Za-z0-9]{36,}|github_pat_[A-Za-z0-9_]{40,})/g },
29
+ { kind: "a Slack token", pattern: /\bxox[abprs]-[A-Za-z0-9-]{20,}/g },
30
+ { kind: "a Google API key", pattern: /\bAIza[0-9A-Za-z_-]{35}\b/g },
31
+ { kind: "an API key for an AI service", pattern: /\bsk-(?:ant|proj)-[A-Za-z0-9_-]{20,}/g },
32
+ { kind: "a Supabase secret key", pattern: /\bsb_secret_[A-Za-z0-9_-]{16,}/g },
33
+ { kind: "a Oneteam access token", pattern: /\bpat_[A-Za-z0-9]{24,}\b/g }
34
+ ];
35
+ var JWT = /\beyJ[A-Za-z0-9_-]{8,}\.(eyJ[A-Za-z0-9_-]{8,})\.[A-Za-z0-9_-]{8,}/g;
36
+ var ASSIGNED = /(?:secret|token|api[_-]?key|service[_-]?(?:role[_-]?)?key|private[_-]?key|password|passwd|credential)[A-Za-z0-9_]*["'`]?\s*[:=]\s*(["'`])([A-Za-z0-9+/_.=-]{24,})\1/gi;
37
+ var PLACEHOLDER = /^(?:x+|your[_-]|example|placeholder|changeme|todo|test[_-]|fake[_-]|dummy)|\.\.\.|<|\$\{/i;
38
+ function decodeBase64Url(value) {
39
+ try {
40
+ return Buffer.from(value.replace(/-/g, "+").replace(/_/g, "/"), "base64").toString("utf8");
41
+ } catch {
42
+ return "";
43
+ }
44
+ }
45
+ function looksGenerated(value) {
46
+ return !PLACEHOLDER.test(value) && /[0-9]/.test(value) && /[A-Za-z]/.test(value) && new Set(value).size >= 12;
47
+ }
48
+ function scanText(file, text, options = {}) {
49
+ const found = /* @__PURE__ */ new Map();
50
+ const lineOf = (index) => {
51
+ let line = 1;
52
+ for (let position = text.indexOf("\n"); position !== -1 && position < index; position = text.indexOf("\n", position + 1)) line += 1;
53
+ return line;
54
+ };
55
+ const add = (index, kind) => {
56
+ const line = lineOf(index);
57
+ if (!found.has(`${line}`)) found.set(`${line}`, { file, line, kind });
58
+ };
59
+ for (const { name, value } of options.knownValues ?? []) {
60
+ if (value.length < 8) continue;
61
+ for (let index = text.indexOf(value); index !== -1; index = text.indexOf(value, index + value.length)) {
62
+ add(index, `the value of ${name} from your local env file`);
63
+ }
64
+ }
65
+ for (const { kind, pattern } of KNOWN_SHAPES) {
66
+ for (const match of text.matchAll(pattern)) add(match.index, kind);
67
+ }
68
+ for (const match of text.matchAll(JWT)) {
69
+ if (/"role"\s*:\s*"(?:service_role|supabase_admin)"/.test(decodeBase64Url(match[1]))) add(match.index, "a Supabase service role key");
70
+ }
71
+ if (options.assignments) {
72
+ for (const match of text.matchAll(ASSIGNED)) {
73
+ if (looksGenerated(match[2])) add(match.index, "a hard-coded key or password");
74
+ }
75
+ }
76
+ return [...found.values()].sort((a, b) => a.line - b.line);
77
+ }
78
+ var TEXT_FILE = /\.(?:[cm]?[jt]sx?|json|jsonc|html?|css|svg|txt|md|map|ya?ml|toml|sql|sh|xml|webmanifest)$/i;
79
+ var MAX_SCANNED_BYTES = 8 * 1024 * 1024;
80
+ function isScannable(path, bytes) {
81
+ return bytes <= MAX_SCANNED_BYTES && (TEXT_FILE.test(path) || !/\.[^/.]+$/.test(path.split("/").pop() ?? ""));
82
+ }
83
+ function parseEnvFile(contents) {
84
+ const entries = [];
85
+ for (const line of contents.split(/\r?\n/)) {
86
+ const match = /^\s*(?:export\s+)?([A-Za-z_][A-Za-z0-9_]*)\s*=\s*(.*)$/.exec(line);
87
+ if (!match) continue;
88
+ let value = match[2].trim();
89
+ const quote = value[0];
90
+ if ((quote === '"' || quote === "'" || quote === "`") && value.endsWith(quote) && value.length >= 2) value = value.slice(1, -1);
91
+ else value = value.replace(/\s+#.*$/, "");
92
+ entries.push({ name: match[1], value });
93
+ }
94
+ return entries;
95
+ }
96
+ function formatFindings(findings) {
97
+ return findings.map((finding) => ` ${finding.file}:${finding.line} looks like ${finding.kind}`);
98
+ }
99
+
100
+ // src/hosted/tar.ts
101
+ import { gzipSync } from "zlib";
102
+ var encoder = new TextEncoder();
103
+ function field(header, offset, length, value) {
104
+ const bytes = encoder.encode(value);
105
+ if (bytes.length > length) throw new Error(`"${value}" does not fit in a tar header field of ${length} bytes`);
106
+ header.set(bytes, offset);
107
+ }
108
+ function splitPath(path) {
109
+ if (encoder.encode(path).length <= 100) return { name: path, prefix: "" };
110
+ for (let index = path.indexOf("/"); index !== -1; index = path.indexOf("/", index + 1)) {
111
+ const prefix = path.slice(0, index);
112
+ const name = path.slice(index + 1);
113
+ if (encoder.encode(prefix).length <= 155 && encoder.encode(name).length <= 100) return { name, prefix };
114
+ }
115
+ throw new Error(`Path is too long to pack: ${path}`);
116
+ }
117
+ function tarGz(entries) {
118
+ const sorted = [...entries].sort((a, b) => a.path < b.path ? -1 : a.path > b.path ? 1 : 0);
119
+ const blocks = [];
120
+ for (const entry of sorted) {
121
+ const header = new Uint8Array(512);
122
+ const { name, prefix } = splitPath(entry.path);
123
+ field(header, 0, 100, name);
124
+ field(header, 100, 8, "0000644\0");
125
+ field(header, 108, 8, "0000000\0");
126
+ field(header, 116, 8, "0000000\0");
127
+ field(header, 124, 12, `${entry.contents.length.toString(8).padStart(11, "0")}\0`);
128
+ field(header, 136, 12, "00000000000\0");
129
+ field(header, 148, 8, " ");
130
+ field(header, 156, 1, "0");
131
+ field(header, 257, 6, "ustar\0");
132
+ field(header, 263, 2, "00");
133
+ field(header, 345, 155, prefix);
134
+ const checksum = header.reduce((sum, byte) => sum + byte, 0);
135
+ field(header, 148, 8, `${checksum.toString(8).padStart(6, "0")}\0 `);
136
+ const padding = (512 - entry.contents.length % 512) % 512;
137
+ blocks.push(header, entry.contents, new Uint8Array(padding));
138
+ }
139
+ blocks.push(new Uint8Array(1024));
140
+ const tar = new Uint8Array(blocks.reduce((total, block) => total + block.length, 0));
141
+ let offset = 0;
142
+ for (const block of blocks) {
143
+ tar.set(block, offset);
144
+ offset += block.length;
145
+ }
146
+ return gzipSync(tar, { level: 9 });
147
+ }
148
+
149
+ // src/hosted/bundle.ts
150
+ var DEFAULTS = {
151
+ worker: "src/worker/index.ts",
152
+ assets: "dist/client",
153
+ migrations: "migrations"
154
+ };
155
+ var NEVER_SOURCE = [/(^|\/)node_modules\//, /(^|\/)\.git\//, /(^|\/)\.wrangler\//, /(^|\/)\.env($|\.)/, /(^|\/)\.dev\.vars($|\.)/, /(^|\/)dist\//];
156
+ var TEST_SOURCE = [/\.(?:test|spec)\.[^/]+$/, /(^|\/)__tests__\//, /(^|\/)vitest\.config\.[^/]+$/];
157
+ var MAX_SOURCE_FILE_BYTES = 1024 * 1024;
158
+ function posix(path) {
159
+ return path.split(sep).join("/");
160
+ }
161
+ function walk(directory, skip = () => false) {
162
+ const found = [];
163
+ const visit = (current) => {
164
+ for (const entry of readdirSync(current, { withFileTypes: true })) {
165
+ const absolute = join(current, entry.name);
166
+ const path = posix(relative(directory, absolute));
167
+ if (entry.isDirectory()) {
168
+ if (!skip(`${path}/`)) visit(absolute);
169
+ } else if (entry.isFile() && !skip(path)) {
170
+ found.push(path);
171
+ }
172
+ }
173
+ };
174
+ visit(directory);
175
+ return found.sort();
176
+ }
177
+ function gitFiles(root) {
178
+ try {
179
+ const output = execFileSync("git", ["ls-files", "--cached", "--others", "--exclude-standard", "-z"], {
180
+ cwd: root,
181
+ stdio: ["ignore", "pipe", "ignore"],
182
+ maxBuffer: 64 * 1024 * 1024
183
+ });
184
+ return output.toString("utf8").split("\0").filter(Boolean);
185
+ } catch {
186
+ return null;
187
+ }
188
+ }
189
+ function sourceFiles(root) {
190
+ const allowed = (path) => ![...NEVER_SOURCE, ...TEST_SOURCE].some((pattern) => pattern.test(path));
191
+ const files = gitFiles(root) ?? walk(root, (path) => !allowed(path));
192
+ return files.filter(allowed).filter((path) => {
193
+ try {
194
+ const stats = lstatSync(join(root, path));
195
+ return stats.isFile() && stats.size <= MAX_SOURCE_FILE_BYTES;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }).sort();
200
+ }
201
+ function localSecretValues(root) {
202
+ const values = [];
203
+ for (const name of readdirSync(root)) {
204
+ if (!isEnvFile(name)) continue;
205
+ try {
206
+ if (lstatSync(join(root, name)).isFile()) values.push(...parseEnvFile(readFileSync(join(root, name), "utf8")));
207
+ } catch {
208
+ }
209
+ }
210
+ return values;
211
+ }
212
+ function scanFile(absolute, label, options) {
213
+ try {
214
+ const stats = lstatSync(absolute);
215
+ if (!stats.isFile() || !isScannable(label, stats.size)) return [];
216
+ return scanText(label, readFileSync(absolute, "utf8"), options);
217
+ } catch {
218
+ return [];
219
+ }
220
+ }
221
+ function findSafetyProblems(root, options = {}) {
222
+ const knownValues = localSecretValues(root);
223
+ const unignoredEnvFiles = (gitFiles(root) ?? []).filter((path) => isEnvFile(path) && existsSync(join(root, path)));
224
+ const assetFiles = options.assetsDirectory && existsSync(options.assetsDirectory) ? walk(options.assetsDirectory) : [];
225
+ const assetLabel = (path) => `${posix(relative(root, options.assetsDirectory))}/${path}`;
226
+ const findings = [];
227
+ for (const path of sourceFiles(root)) findings.push(...scanFile(join(root, path), path, { assignments: true, knownValues }));
228
+ for (const path of assetFiles) findings.push(...scanFile(join(options.assetsDirectory, path), assetLabel(path), { knownValues }));
229
+ if (options.worker !== void 0) findings.push(...scanText("the bundled Worker", options.worker, { knownValues }));
230
+ return { unignoredEnvFiles, envFilesInAssets: assetFiles.filter(isEnvFile).map(assetLabel), findings };
231
+ }
232
+ function hasSafetyProblems(problems) {
233
+ return problems.unignoredEnvFiles.length + problems.envFilesInAssets.length + problems.findings.length > 0;
234
+ }
235
+ function describeSafetyProblems(problems) {
236
+ const lines = [];
237
+ if (problems.unignoredEnvFiles.length > 0) {
238
+ lines.push(
239
+ `Env files that are not git-ignored: ${problems.unignoredEnvFiles.join(", ")}`,
240
+ " They hold values and would be committed with the code. Add them to .gitignore (`.dev.vars*` and `.env*`),",
241
+ " and if one was committed before: remove it from git and replace the keys in it."
242
+ );
243
+ }
244
+ if (problems.envFilesInAssets.length > 0) {
245
+ lines.push(
246
+ `Env files in the built front end: ${problems.envFilesInAssets.join(", ")}`,
247
+ " Everything there is downloaded by the browser. Remove them from the front end (e.g. from public/)."
248
+ );
249
+ }
250
+ if (problems.findings.length > 0) {
251
+ lines.push(
252
+ "Credentials written into the code:",
253
+ ...formatFindings(problems.findings),
254
+ ' Take the value out of the code. List its name under "secrets" in oneteam.app.json, read it in the Worker with',
255
+ ' secret(c.env, "NAME"), and have a person enter the value: `oneteam-apps secret set NAME --env preview` (and live),',
256
+ " or the app's Secrets tab in the dashboard. A key that was in the code is no longer secret: replace it at its source."
257
+ );
258
+ }
259
+ return lines;
260
+ }
261
+ function packageManager(root) {
262
+ for (let directory = root; ; directory = join(directory, "..")) {
263
+ if (existsSync(join(directory, "pnpm-lock.yaml"))) return "pnpm";
264
+ if (existsSync(join(directory, "yarn.lock"))) return "yarn";
265
+ if (existsSync(join(directory, "bun.lock")) || existsSync(join(directory, "bun.lockb"))) return "bun";
266
+ if (existsSync(join(directory, "package-lock.json"))) return "npm";
267
+ if (join(directory, "..") === directory) return "npm";
268
+ }
269
+ }
270
+ function buildProject(root, log) {
271
+ const scripts = JSON.parse(readFileSync(join(root, "package.json"), "utf8")).scripts ?? {};
272
+ if (!scripts.build) throw new Error('package.json has no "build" script. Add one that builds the front end (e.g. "vite build"), or pass --skip-build.');
273
+ const manager = packageManager(root);
274
+ log(`Building the front end (${manager} run build)\u2026`);
275
+ const result = spawnSync(manager, ["run", "build"], { cwd: root, encoding: "utf8", shell: process.platform === "win32" });
276
+ if (result.error) throw new Error(`Could not run "${manager} run build": ${result.error.message}`);
277
+ if (result.status !== 0) {
278
+ throw new Error(`The build failed:
279
+ ${[result.stdout, result.stderr].filter(Boolean).join("\n").trim().split("\n").slice(-40).join("\n")}`);
280
+ }
281
+ }
282
+ async function bundleWorker(root, entry) {
283
+ const { build } = await import("esbuild");
284
+ const result = await build({
285
+ absWorkingDir: root,
286
+ entryPoints: [entry],
287
+ bundle: true,
288
+ write: false,
289
+ format: "esm",
290
+ target: "es2022",
291
+ platform: "neutral",
292
+ mainFields: ["browser", "module", "main"],
293
+ conditions: ["workerd", "worker", "browser"],
294
+ // Provided by the runtime.
295
+ external: ["cloudflare:*", "node:*"],
296
+ define: { "process.env.NODE_ENV": '"production"' },
297
+ // Small, without comments (the review reads `source/`, and scans this file
298
+ // for hosts the app might call), but with real names in stack traces.
299
+ minifyWhitespace: true,
300
+ minifySyntax: true,
301
+ minifyIdentifiers: false,
302
+ legalComments: "none",
303
+ logLevel: "silent"
304
+ }).catch((error) => {
305
+ const details = (error.errors ?? []).map((item) => ` ${item.location ? `${item.location.file}:${item.location.line}: ` : ""}${item.text}`);
306
+ throw new Error(`Could not bundle the Worker (${entry}):
307
+ ${details.join("\n") || error.message}`);
308
+ });
309
+ return result.outputFiles[0].text;
310
+ }
311
+ async function createBundle(root, options = {}) {
312
+ const log = options.log ?? (() => {
313
+ });
314
+ const workerEntry = options.worker ?? DEFAULTS.worker;
315
+ const assetsDirectory = join(root, options.assets ?? DEFAULTS.assets);
316
+ const migrationsDirectory = join(root, DEFAULTS.migrations);
317
+ if (!existsSync(join(root, workerEntry))) {
318
+ throw new Error(`${workerEntry} not found. A hosted app's API lives there (see the template), or pass --worker <file>.`);
319
+ }
320
+ if (!options.skipBuild) buildProject(root, log);
321
+ if (!existsSync(join(assetsDirectory, "index.html"))) {
322
+ throw new Error(`${options.assets ?? DEFAULTS.assets}/index.html not found after the build. Pass --assets <directory> if the front end is built elsewhere.`);
323
+ }
324
+ const worker = await bundleWorker(root, workerEntry);
325
+ const problems = findSafetyProblems(root, { assetsDirectory, worker });
326
+ if (hasSafetyProblems(problems)) {
327
+ throw new Error(["Not uploaded: the app would carry secrets with it.", "", ...describeSafetyProblems(problems)].join("\n"));
328
+ }
329
+ const entries = [{ path: BUNDLE_LAYOUT.worker, contents: new TextEncoder().encode(worker) }];
330
+ const assets = walk(assetsDirectory, (path) => path.split("/").some((part) => part.startsWith(".")));
331
+ const migrations = existsSync(migrationsDirectory) ? readdirSync(migrationsDirectory).filter((name) => name.endsWith(".sql") && lstatSync(join(migrationsDirectory, name)).isFile()).sort() : [];
332
+ const source = sourceFiles(root);
333
+ for (const path of assets) entries.push({ path: `${BUNDLE_LAYOUT.assets}${path}`, contents: readFileSync(join(assetsDirectory, path)) });
334
+ for (const name of migrations) entries.push({ path: `${BUNDLE_LAYOUT.migrations}${name}`, contents: readFileSync(join(migrationsDirectory, name)) });
335
+ for (const path of source) entries.push({ path: `${BUNDLE_LAYOUT.source}${path}`, contents: readFileSync(join(root, path)) });
336
+ const archive = tarGz(entries);
337
+ if (archive.length > MAX_BUNDLE_BYTES) {
338
+ throw new Error(
339
+ `The bundle is ${(archive.length / 1024 / 1024).toFixed(1)} MB; the limit is ${MAX_BUNDLE_BYTES / 1024 / 1024} MB. Large files (images, video, data) usually cause this: remove them or load them from elsewhere.`
340
+ );
341
+ }
342
+ return { archive, files: { worker: 1, assets: assets.length, migrations: migrations.length, source: source.length } };
343
+ }
344
+
345
+ // src/hosted/job-check.ts
346
+ import { existsSync as existsSync2, readdirSync as readdirSync2, readFileSync as readFileSync2 } from "fs";
347
+ import { join as join2 } from "path";
348
+ var WORKER_DIRECTORY = "src/worker";
349
+ var SOURCE = /\.[cm]?[jt]sx?$/;
350
+ var TEST = /\.(?:test|spec)\.[^/]+$/;
351
+ var DEV_ENTRY = /(^|\/)dev\.[cm]?[jt]sx?$/;
352
+ function workerSources(root) {
353
+ const directory = join2(root, WORKER_DIRECTORY);
354
+ if (!existsSync2(directory)) return [];
355
+ const walk3 = (current, prefix) => readdirSync2(current, { withFileTypes: true }).flatMap(
356
+ (entry) => entry.isDirectory() ? walk3(join2(current, entry.name), `${prefix}/${entry.name}`) : entry.isFile() && SOURCE.test(entry.name) && !TEST.test(entry.name) && !DEV_ENTRY.test(entry.name) ? [`${prefix}/${entry.name}`] : []
357
+ );
358
+ return walk3(directory, WORKER_DIRECTORY).map((file) => ({ file, source: readFileSync2(join2(root, file), "utf8") }));
359
+ }
360
+ function skeleton(source) {
361
+ const out2 = source.split("");
362
+ const strings = /* @__PURE__ */ new Map();
363
+ let index = 0;
364
+ const blank = (from, to) => {
365
+ for (let position = from; position < to; position += 1) if (out2[position] !== "\n") out2[position] = " ";
366
+ };
367
+ while (index < source.length) {
368
+ const char = source[index];
369
+ const next = source[index + 1];
370
+ if (char === "/" && next === "/") {
371
+ const end = source.indexOf("\n", index);
372
+ blank(index, end === -1 ? source.length : end);
373
+ index = end === -1 ? source.length : end;
374
+ } else if (char === "/" && next === "*") {
375
+ const end = source.indexOf("*/", index + 2);
376
+ blank(index, end === -1 ? source.length : end + 2);
377
+ index = end === -1 ? source.length : end + 2;
378
+ } else if (char === '"' || char === "'" || char === "`") {
379
+ let end = index + 1;
380
+ while (end < source.length && source[end] !== char) end += source[end] === "\\" ? 2 : 1;
381
+ strings.set(index, source.slice(index + 1, end));
382
+ blank(index + 1, end);
383
+ index = end + 1;
384
+ } else {
385
+ index += 1;
386
+ }
387
+ }
388
+ return { code: out2.join(""), strings };
389
+ }
390
+ function jobNamesIn(source) {
391
+ const { code, strings } = skeleton(source);
392
+ const call = /\bdefineJobs\s*(?:<[^>(]*>)?\s*\(/.exec(code);
393
+ if (!call) return null;
394
+ let index = call.index + call[0].length;
395
+ while (/\s/.test(code[index] ?? "")) index += 1;
396
+ if (code[index] !== "{") return { names: [], unreadable: true };
397
+ const names = [];
398
+ let unreadable = false;
399
+ let depth = 0;
400
+ let expectName = false;
401
+ for (; index < code.length; index += 1) {
402
+ const char = code[index];
403
+ if (char === "{" || char === "(" || char === "[") {
404
+ if (expectName && depth === 1 && char === "[") unreadable = true;
405
+ depth += 1;
406
+ expectName = depth === 1;
407
+ continue;
408
+ }
409
+ if (char === "}" || char === ")" || char === "]") {
410
+ depth -= 1;
411
+ if (depth === 0) break;
412
+ continue;
413
+ }
414
+ if (depth !== 1) continue;
415
+ if (char === ",") {
416
+ expectName = true;
417
+ continue;
418
+ }
419
+ if (!expectName || /\s/.test(char)) continue;
420
+ if (char === '"' || char === "'" || char === "`") {
421
+ names.push(strings.get(index) ?? "");
422
+ index += (strings.get(index) ?? "").length + 1;
423
+ } else if (code.startsWith("...", index)) {
424
+ unreadable = true;
425
+ } else {
426
+ const word = /^(?:async\s+)?\*?\s*([A-Za-z_$][\w$]*)/.exec(code.slice(index));
427
+ if (word) {
428
+ names.push(word[1]);
429
+ index += word[0].length - 1;
430
+ }
431
+ }
432
+ expectName = false;
433
+ }
434
+ return { names, unreadable };
435
+ }
436
+ function findJobs(root) {
437
+ const sources = workerSources(root);
438
+ const found = { names: [], file: null, unreadable: false };
439
+ const uses = { notify: false, sendChatMessage: false };
440
+ let wired = false;
441
+ for (const { file, source } of sources) {
442
+ const { code } = skeleton(source);
443
+ const jobs = jobNamesIn(source);
444
+ if (jobs) {
445
+ found.names.push(...jobs.names);
446
+ found.file ??= file;
447
+ found.unreadable ||= jobs.unreadable;
448
+ }
449
+ wired ||= /\bwithJobs\s*(?:<[^()]*>)?\s*\(/.test(code) || /\.handle\s*\(/.test(code);
450
+ uses.notify ||= /\bnotify\s*\(/.test(code) && /\bimport\b[^;]*\bnotify\b/.test(code);
451
+ uses.sendChatMessage ||= /\bsendChatMessage\s*\(/.test(code) && /\bimport\b[^;]*\bsendChatMessage\b/.test(code);
452
+ }
453
+ return { ...found, wired, uses };
454
+ }
455
+ function checkJobsAndSends(root, manifest2) {
456
+ const errors = [];
457
+ const warnings = [];
458
+ const found = findJobs(root);
459
+ const scheduled = manifest2.schedules.map((schedule) => schedule.name);
460
+ if (scheduled.length > 0 && found.file === null) {
461
+ errors.push(
462
+ `${MANIFEST_FILE} schedules ${scheduled.map((name) => `"${name}"`).join(", ")}, but the Worker defines no jobs. Add \`export const jobs = defineJobs({ \u2026 })\` with a handler per name (from @oneteam-app/apps/hosted) and \`export default withJobs(app, jobs)\` in src/worker/index.ts.`
463
+ );
464
+ } else if (found.unreadable) {
465
+ warnings.push(`${found.file}: the jobs passed to defineJobs() could not be read. List each handler by name inside defineJobs({ \u2026 }) so the schedules can be checked before a deploy.`);
466
+ } else {
467
+ for (const name of scheduled.filter((item) => !found.names.includes(item))) {
468
+ errors.push(`${MANIFEST_FILE} schedules "${name}", but ${found.file} has no handler of that name in defineJobs(). The job would fail every time it runs.`);
469
+ }
470
+ for (const name of found.names.filter((item) => !scheduled.includes(item))) {
471
+ errors.push(`${found.file} has a handler "${name}" that ${MANIFEST_FILE} does not list under "schedules": it would never run. Add { "name": "${name}", "every": \u2026 } there, or remove the handler.`);
472
+ }
473
+ }
474
+ if (found.file !== null && found.names.length > 0 && !found.wired) {
475
+ errors.push(`${found.file} defines jobs, but nothing puts them in front of the Worker. Use \`export default withJobs(app, jobs)\` in src/worker/index.ts.`);
476
+ }
477
+ for (const [used, kind, call] of [
478
+ [found.uses.notify, "notifications", "notify()"],
479
+ [found.uses.sendChatMessage, "chat_messages", "sendChatMessage()"]
480
+ ]) {
481
+ if (used && !manifest2.sends.includes(kind)) {
482
+ errors.push(`The Worker calls ${call}, but ${MANIFEST_FILE} does not list "${kind}" under "sends". Add it: an admin approves what an app may send, and without it every call is refused.`);
483
+ } else if (!used && manifest2.sends.includes(kind)) {
484
+ warnings.push(`${MANIFEST_FILE} lists "${kind}" under "sends", but the Worker never calls ${call}. Remove it, so an admin is not asked to approve something the app does not do.`);
485
+ }
486
+ }
487
+ return { errors, warnings };
488
+ }
489
+
490
+ // src/hosted/ui-check.ts
491
+ import { existsSync as existsSync3, readdirSync as readdirSync3, readFileSync as readFileSync3 } from "fs";
492
+ import { join as join3 } from "path";
493
+ var CLIENT = "src/client";
494
+ var SOURCE2 = /\.(?:[jt]sx?|css)$/;
495
+ var TEST2 = /\.(?:test|spec)\.[^/]+$/;
496
+ var ARBITRARY_COLOR = /-\[(?:color:|#|(?:rgba?|hsla?|oklch|oklab|lab|lch)\()[^\]]*\]/i;
497
+ var INLINE_STYLE_COLOR = /style=\{\{[^}]*\b(?:color|background|backgroundColor|borderColor|outlineColor|fill|stroke)\s*:\s*(?!\s*["'`]var\(--)/;
498
+ var ENTRY_STYLESHEET = "src/client/styles.css";
499
+ var COLOR_VALUE = /#(?:[0-9a-f]{8}|[0-9a-f]{6}|[0-9a-f]{3,4})\b|\b(?:rgba?|hsla?|oklch|oklab|lab|lch)\(/i;
500
+ var PALETTE_CLASS = /(?<![\w-])(?:bg|text|border|ring|outline|fill|stroke|from|via|to|divide|decoration|shadow|accent|caret|placeholder)-(?:(?:slate|gray|zinc|neutral|stone|red|orange|amber|yellow|lime|green|emerald|teal|cyan|sky|blue|indigo|violet|purple|fuchsia|pink|rose)-\d{2,3}|white|black)(?![\w-])/;
501
+ var RAW_ELEMENTS = [
502
+ [/<button[\s>]/, "a hand-made <button>: use <Button> from @oneteam-app/ui"],
503
+ [/<input[\s>/]/, "a hand-made <input>: use <TextInput>, <DateInput>, <Checkbox>, <Switch>, <SearchField> or <FilePicker> inside a <Field>"],
504
+ [/<textarea[\s>]/, "a hand-made <textarea>: use <Textarea> inside a <Field>"],
505
+ [/<select[\s>]/, "a hand-made <select>: use <Select> inside a <Field>"],
506
+ [/<dialog[\s>]|role=["']dialog["']|window\.confirm\(|(?<![\w.])confirm\(/, "a hand-made dialog: use <ConfirmDialog> or <Sheet>"]
507
+ ];
508
+ function walk2(directory, prefix) {
509
+ return readdirSync3(directory, { withFileTypes: true }).flatMap((entry) => {
510
+ const path = `${prefix}/${entry.name}`;
511
+ if (entry.isDirectory()) return entry.name === "node_modules" ? [] : walk2(join3(directory, entry.name), path);
512
+ return entry.isFile() && SOURCE2.test(entry.name) && !TEST2.test(entry.name) ? [path] : [];
513
+ });
514
+ }
515
+ function uiWarningsIn(file, source) {
516
+ const warnings = [];
517
+ const markup = /\.[jt]sx$/.test(file);
518
+ let inComment = false;
519
+ source.split("\n").forEach((text, index) => {
520
+ if (inComment) {
521
+ inComment = !text.includes("*/");
522
+ return;
523
+ }
524
+ if (/^\s*\/\*/.test(text) && !text.includes("*/")) {
525
+ inComment = true;
526
+ return;
527
+ }
528
+ if (/^\s*(?:\/\/|\*|\/\*)/.test(text)) return;
529
+ const add = (message) => warnings.push({ file, line: index + 1, message });
530
+ if (ARBITRARY_COLOR.test(text)) add("a colour in an arbitrary value (bg-[#\u2026]): use the theme's names (bg-primary, text-muted-foreground, border-border\u2026)");
531
+ else if (markup && INLINE_STYLE_COLOR.test(text)) add("a colour in an inline style: use a Tailwind class with one of the theme's names (bg-card, text-destructive\u2026)");
532
+ else if (COLOR_VALUE.test(text)) add("a colour written out: use the theme (bg-card, text-muted-foreground, var(--primary)\u2026) so the organisation's colours and dark mode apply");
533
+ else if (PALETTE_CLASS.test(text)) add("a Tailwind palette colour: use the theme's (bg-primary, text-destructive, border-border\u2026) so the organisation's colours and dark mode apply");
534
+ if (markup) {
535
+ for (const [pattern, message] of RAW_ELEMENTS) if (pattern.test(text)) add(message);
536
+ }
537
+ });
538
+ return warnings;
539
+ }
540
+ function findUiWarnings(root) {
541
+ const directory = join3(root, CLIENT);
542
+ if (!existsSync3(directory)) return [];
543
+ return walk2(directory, CLIENT).flatMap((file) => [
544
+ // One stylesheet, the one the template starts with. Layout is Tailwind classes in the markup.
545
+ ...file.endsWith(".css") && file !== ENTRY_STYLESHEET ? [{ file, line: 1, message: "a stylesheet of its own: style with Tailwind classes in the markup; src/client/styles.css stays the only .css file" }] : [],
546
+ ...uiWarningsIn(file, readFileSync3(join3(root, file), "utf8"))
547
+ ]);
548
+ }
549
+ function usesUiPackage(root) {
550
+ try {
551
+ const pkg = JSON.parse(readFileSync3(join3(root, "package.json"), "utf8"));
552
+ return Boolean(pkg.dependencies?.["@oneteam-app/ui"] ?? pkg.devDependencies?.["@oneteam-app/ui"]);
553
+ } catch {
554
+ return false;
555
+ }
556
+ }
10
557
 
11
558
  // src/generated/instructions.ts
12
- var AGENT_INSTRUCTIONS = "# Oneteam login and authorization\n\nApps built this way are opened from a Oneteam shortcut. Oneteam signs the\nemployee in (no extra password) and tells the app who they are and which\nOneteam permissions they hold. Authorization is RBAC on those permissions:\nadmins grant them through Oneteam roles, and the app checks them by name\n(`network::users.update`). Apps have no permissions or roles of their own.\n\n**Supported today**, with `@oneteam-app/apps`:\n\n- **TanStack Start** (Lovable's default, also on Cloudflare Workers): `@oneteam-app/apps/tanstack-start`\n- **Next.js App Router** (Next 15.2+, 16 recommended): `@oneteam-app/apps/next`\n\nOn any other stack, stop and tell the user it isn't supported yet.\n\n## First: the Oneteam connector\n\nRegistering the app needs the Oneteam MCP connector, which gives you the tools\n`get_app_setup_guide` and `register_app`. If you don't have them, stop and ask\nthe user to add it before anything else. In Lovable:\n\n1. Open **Connectors**, click **+** and choose **MCP server**.\n2. **Server name:** `Oneteam`. **Server URL:** `https://api.oneteam.io/mcp`\n (a Oneteam test environment: its API URL followed by `/mcp`).\n3. Keep **OAuth**, click **Add & authorize** and sign in with Oneteam as an\n organisation admin.\n\nThen call `get_app_setup_guide`. In Claude, Cursor and other MCP clients the\nconnector is added the same way, with the same URL.\n\nNever ask the user for Oneteam secrets before `register_app` has returned them:\nthey don't exist until the app is registered, and registering needs the app's\npublished URL.\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** Next.js: `proxy.ts` runs `oneteam.proxy()` for every route.\n TanStack Start: `src/start.ts` has a request middleware that calls\n `oneteam.guard()`. Add to `publicPaths` only what anyone on the internet may\n open (health checks, a public landing page), and ask the user first.\n- **Never exclude routes from the gate by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The gate is a safety net, not the check.** Server actions, server functions\n and route handlers still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Everything that reads or\n changes protected data runs on the server (Next.js server action or route\n handler, TanStack Start server function or server route) and calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('network::users.update', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, permission)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_` or `VITE_`, never put them in code,\n never log tokens or sessions, never pass `getAccessToken()` results to the browser.\n TanStack Start: import `oneteam.server.ts` only from server functions, server\n routes and `src/start.ts`.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Authorize on Oneteam permissions only.** Not on a role, an admin flag, a\n function name, an email domain or a hard-coded list of user ids. Pick each\n permission from `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`\n (online: https://unpkg.com/@oneteam-app/apps/docs/oneteam-permissions.md)\n (typed as `OneteamPermission`, so a wrong name doesn't compile) and name the\n ones the app uses once, e.g. in `lib/permissions.ts`. Never invent a name or\n match on a prefix.\n- **Community permissions are not organisation-wide.** A `network:` permission\n without `communityId` is only true for users who hold the same action\n organisation-wide. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\nTanStack Start (Lovable): full files in [references/tanstack-start.md](references/tanstack-start.md).\n\n```ts\n// src/lib/oneteam.server.ts\nimport { createOneteam } from '@oneteam-app/apps/tanstack-start';\nimport manifest from '../../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// src/routes/api/oneteam/$.ts\nexport const Route = createFileRoute('/api/oneteam/$')({ server: { handlers: oneteam.handlers } });\n\n// src/start.ts \u2014 deny by default\nconst oneteamMiddleware = createMiddleware().server((ctx) => oneteam.guard(ctx, { publicPaths: ['/health'] }));\nexport const startInstance = createStart(() => ({ requestMiddleware: [oneteamMiddleware] }));\n\n// any server function that touches protected data\nexport const approveExpense = createServerFn({ method: 'POST' })\n .validator((id: string) => id)\n .handler(async ({ data: id }) => {\n const expense = await db.expense.findUniqueOrThrow({ where: { id } });\n await oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n // ...\n });\n```\n\nNext.js:\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// lib/permissions.ts \u2014 the Oneteam permissions this app checks\nexport const APPROVE_EXPENSES = 'network::users.update' satisfies OneteamPermission;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n```\n\n## Workflow\n\nDo these in order. Don't skip the checks at the end.\n\n1. **Confirm the stack.** TanStack Start: `package.json` has `@tanstack/react-start`\n and there is a `src/routes/` directory. Next.js: `package.json` has `next` and\n there is an `app/` or `src/app/` directory.\n2. **Scaffold.**\n ```bash\n npm install @oneteam-app/apps\n npx @oneteam-app/apps-cli init --url https://<production-domain>\n ```\n Ask the user for the production domain if you don't know it. Never guess one,\n and never use a preview URL (`*.vercel.app` previews, Lovable's editor preview)\n as the production domain; a published `https://<name>.lovable.app` is fine.\n `init` creates the setup files of [references/tanstack-start.md](references/tanstack-start.md)\n or [references/nextjs.md](references/nextjs.md), `oneteam.app.json` and\n `.env.example` entries. It won't overwrite an existing `src/start.ts`,\n `middleware.ts` or `proxy.ts`: add the gate to it yourself.\n **Without a terminal (Lovable):** add `@oneteam-app/apps` as a dependency and\n create those files yourself, exactly as in the reference.\n3. **Declare what the app needs** in `oneteam.app.json` `scopes`:\n `oneteam:permissions` (scaffolded) for permission checks, `oneteam:communities`\n for memberships and for permissions per community, `oneteam:functions` for\n functions, `oneteam:organisation` for the employee number. Request only what\n the app uses; admins see and approve each one. Then run\n `npx @oneteam-app/apps-cli validate`.\n4. **Choose the permissions.** For each action not every employee may do, pick\n the Oneteam permission whose holders should be allowed, from\n `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`: its \"Granted by\"\n column is the role editor toggle admins use. Use a `network:` permission when\n the action belongs to a store/location/team, an `organisation:` one when it is\n organisation-wide. Tell the user which permissions you chose and why.\n5. **Protect the app** following [references/tanstack-start.md](references/tanstack-start.md)\n or [references/nextjs.md](references/nextjs.md) and the rules above.\n6. **Register the app.** With the Oneteam MCP connector, an organisation admin\n can do it from this chat: call `register_app` with the app's production URL\n (and its localhost URL). Otherwise an admin registers it in the Oneteam\n dashboard. Either way the app starts in development status (only the person\n who registered it can sign in) and comes with a client id and secret. The user\n stores them as server-side secrets themselves (Lovable: project secrets;\n otherwise `.env.local` and the hosting provider's secret settings), together\n with `ONETEAM_SESSION_SECRET`, `ONETEAM_APP_URL` and, when the returned\n `issuer` isn't `https://login.oneteam.io` (a test environment),\n `ONETEAM_ISSUER` and `ONETEAM_API_URL` with the returned values. Never ask\n them to paste the secret into the chat, and never repeat a secret you were shown.\n7. **Verify:** `npx @oneteam-app/apps-cli doctor`, `npm run build`, then walk\n through [references/checklist.md](references/checklist.md) and report the\n result to the user.\n\n## What you cannot do for the user\n\nSay these plainly instead of reporting success:\n\n- Registering the app in Oneteam and getting it approved by an admin.\n- Putting the client id and secret into `.env.local` and the hosting provider.\n- Making sure the right people hold the chosen permissions through their Oneteam roles.\n\nUntil those happen, login will fail with `access_denied` or\n`not_a_oneteam_app_client`, however correct the code is.\n\n## Reference\n\n- [references/tanstack-start.md](references/tanstack-start.md): API and patterns for the TanStack Start adapter (Lovable)\n- [references/nextjs.md](references/nextjs.md): API and patterns for the Next.js adapter\n- [references/checklist.md](references/checklist.md): final security review\n- [references/oneteam-permissions.md](references/oneteam-permissions.md): every Oneteam permission name\n- Example app: `examples/next-app` in the oneteam-apps repository\n- Local testing without Oneteam: `pnpm dev:fake` in that repository";
559
+ var AGENT_INSTRUCTIONS = "# Oneteam login and authorization\n\nApps built this way are opened from a Oneteam shortcut. Oneteam signs the\nemployee in (no extra password) and tells the app who they are and which\nOneteam permissions they hold. Authorization is RBAC on those permissions:\nadmins grant them through Oneteam roles, and the app checks them by name\n(`network::users.update`). Apps have no permissions or roles of their own.\n\n**Supported today**, with `@oneteam-app/apps`:\n\n- **TanStack Start** (Lovable's default, also on Cloudflare Workers): `@oneteam-app/apps/tanstack-start`\n- **Next.js App Router** (Next 15.2+, 16 recommended): `@oneteam-app/apps/next`\n\nOn any other stack, stop and tell the user it isn't supported yet.\n\n**Is this the right way to build it?** For a new, small internal tool (a form,\na log, a checklist, a planner), an app that Oneteam hosts is less to run and\nsafer by default: sign-in, a database, a review of every upload, an admin's\napproval and secrets nobody can read back come with it, and there is no client\nsecret or session secret to look after. Say so, and use the `build-app`\ninstructions (`npx @oneteam-app/apps-cli init --hosted <name>`). Use the\ninstructions below when the app already exists, needs a stack or a service the\nhosted platform does not offer, or has to run on the user's own hosting: then\nhosting, the database, the secrets and their rotation are the user's to secure,\nand nobody reviews the code before colleagues use it.\n\n## First: the Oneteam connector\n\nRegistering the app needs the Oneteam MCP connector, which gives you the tools\n`get_app_setup_guide` and `register_app`. If you don't have them, stop and ask\nthe user to add it before anything else. In Lovable:\n\n1. Open **Connectors**, click **+** and choose **MCP server**.\n2. **Server name:** `Oneteam`. **Server URL:** `https://api.oneteam.io/mcp`\n (a Oneteam test environment: its API URL followed by `/mcp`).\n3. Keep **OAuth**, click **Add & authorize** and sign in with Oneteam as an\n organisation admin.\n\nThen call `get_app_setup_guide`. In Claude, Cursor and other MCP clients the\nconnector is added the same way, with the same URL.\n\nNever ask the user for Oneteam secrets before `register_app` has returned them:\nthey don't exist until the app is registered, and registering needs the app's\npublished URL.\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** Next.js: `proxy.ts` runs `oneteam.proxy()` for every route.\n TanStack Start: `src/start.ts` has a request middleware that calls\n `oneteam.guard()`. Add to `publicPaths` only what anyone on the internet may\n open (health checks, a public landing page), and ask the user first.\n- **Never exclude routes from the gate by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The gate is a safety net, not the check.** Server actions, server functions\n and route handlers still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Everything that reads or\n changes protected data runs on the server (Next.js server action or route\n handler, TanStack Start server function or server route) and calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('network::users.update', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, permission)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_` or `VITE_`, never put them in code,\n never log tokens or sessions, never pass `getAccessToken()` results to the browser.\n TanStack Start: import `oneteam.server.ts` only from server functions, server\n routes and `src/start.ts`.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Authorize on Oneteam permissions only.** Not on a role, an admin flag, a\n function name, an email domain or a hard-coded list of user ids. Pick each\n permission from `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`\n (online: https://unpkg.com/@oneteam-app/apps/docs/oneteam-permissions.md)\n (typed as `OneteamPermission`, so a wrong name doesn't compile) and name the\n ones the app uses once, e.g. in `lib/permissions.ts`. Never invent a name or\n match on a prefix.\n- **Community permissions are not organisation-wide.** A `network:` permission\n without `communityId` is only true for users who hold the same action\n organisation-wide. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\nTanStack Start (Lovable): full files in [references/tanstack-start.md](references/tanstack-start.md).\n\n```ts\n// src/lib/oneteam.server.ts\nimport { createOneteam } from '@oneteam-app/apps/tanstack-start';\nimport manifest from '../../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// src/routes/api/oneteam/$.ts\nexport const Route = createFileRoute('/api/oneteam/$')({ server: { handlers: oneteam.handlers } });\n\n// src/start.ts \u2014 deny by default\nconst oneteamMiddleware = createMiddleware().server((ctx) => oneteam.guard(ctx, { publicPaths: ['/health'] }));\nexport const startInstance = createStart(() => ({ requestMiddleware: [oneteamMiddleware] }));\n\n// any server function that touches protected data\nexport const approveExpense = createServerFn({ method: 'POST' })\n .validator((id: string) => id)\n .handler(async ({ data: id }) => {\n const expense = await db.expense.findUniqueOrThrow({ where: { id } });\n await oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n // ...\n });\n```\n\nNext.js:\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// lib/permissions.ts \u2014 the Oneteam permissions this app checks\nexport const APPROVE_EXPENSES = 'network::users.update' satisfies OneteamPermission;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n```\n\n## Workflow\n\nDo these in order. Don't skip the checks at the end.\n\n1. **Confirm the stack.** TanStack Start: `package.json` has `@tanstack/react-start`\n and there is a `src/routes/` directory. Next.js: `package.json` has `next` and\n there is an `app/` or `src/app/` directory.\n2. **Scaffold.**\n ```bash\n npm install @oneteam-app/apps\n npx @oneteam-app/apps-cli init --url https://<production-domain>\n ```\n Ask the user for the production domain if you don't know it. Never guess one,\n and never use a preview URL (`*.vercel.app` previews, Lovable's editor preview)\n as the production domain; a published `https://<name>.lovable.app` is fine.\n `init` creates the setup files of [references/tanstack-start.md](references/tanstack-start.md)\n or [references/nextjs.md](references/nextjs.md), `oneteam.app.json` and\n `.env.example` entries. It won't overwrite an existing `src/start.ts`,\n `middleware.ts` or `proxy.ts`: add the gate to it yourself.\n **Without a terminal (Lovable):** add `@oneteam-app/apps` as a dependency and\n create those files yourself, exactly as in the reference.\n3. **Declare what the app needs** in `oneteam.app.json` `scopes`:\n `oneteam:permissions` (scaffolded) for permission checks, `oneteam:communities`\n for memberships and for permissions per community, `oneteam:functions` for\n functions, `oneteam:organisation` for the employee number. Request only what\n the app uses; admins see and approve each one. Then run\n `npx @oneteam-app/apps-cli validate`.\n4. **Choose the permissions.** For each action not every employee may do, pick\n the Oneteam permission whose holders should be allowed, from\n `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`: its \"Granted by\"\n column is the role editor toggle admins use. Use a `network:` permission when\n the action belongs to a store/location/team, an `organisation:` one when it is\n organisation-wide. Tell the user which permissions you chose and why.\n5. **Protect the app** following [references/tanstack-start.md](references/tanstack-start.md)\n or [references/nextjs.md](references/nextjs.md) and the rules above.\n6. **Register the app.** With the Oneteam MCP connector, an organisation admin\n can do it from this chat: call `register_app` with the app's production URL\n (and its localhost URL). Otherwise an admin registers it in the Oneteam\n dashboard. Either way the app starts in development status (only the person\n who registered it can sign in) and comes with a client id and secret. The user\n stores them as server-side secrets themselves (Lovable: project secrets;\n otherwise `.env.local` and the hosting provider's secret settings), together\n with `ONETEAM_SESSION_SECRET`, `ONETEAM_APP_URL` and, when the returned\n `issuer` isn't `https://login.oneteam.io` (a test environment),\n `ONETEAM_ISSUER` and `ONETEAM_API_URL` with the returned values. Never ask\n them to paste the secret into the chat, and never repeat a secret you were shown.\n7. **Verify:** `npx @oneteam-app/apps-cli doctor`, `npm run build`, then walk\n through [references/checklist.md](references/checklist.md) and report the\n result to the user.\n8. **Offer a shortcut.** Once the app is published and login works, offer to add\n it to Oneteam with `create_shortcut` and the `app_id` that `register_app`\n returned. Colleagues then open it from Oneteam, on the web and in the mobile\n app, already signed in. Ask the user which communities or functions should\n see it; never guess the audience.\n\n## What you cannot do for the user\n\nSay these plainly instead of reporting success:\n\n- Registering the app in Oneteam and getting it approved by an admin.\n- Putting the client id and secret into `.env.local` and the hosting provider.\n- Making sure the right people hold the chosen permissions through their Oneteam roles.\n\nUntil those happen, login will fail with `access_denied` or\n`not_a_oneteam_app_client`, however correct the code is.\n\n## Reference\n\n- [references/tanstack-start.md](references/tanstack-start.md): API and patterns for the TanStack Start adapter (Lovable)\n- [references/nextjs.md](references/nextjs.md): API and patterns for the Next.js adapter\n- [references/checklist.md](references/checklist.md): final security review\n- [references/oneteam-permissions.md](references/oneteam-permissions.md): every Oneteam permission name\n- Example app: `examples/next-app` in the oneteam-apps repository\n- Local testing without Oneteam: `pnpm dev:fake` in that repository";
13
560
  var CURSOR_RULE = "---\ndescription: Oneteam login and authorization rules for this app\nalwaysApply: true\n---\n\n## First: the Oneteam connector\n\nRegistering the app needs the Oneteam MCP connector, which gives you the tools\n`get_app_setup_guide` and `register_app`. If you don't have them, stop and ask\nthe user to add it before anything else. In Lovable:\n\n1. Open **Connectors**, click **+** and choose **MCP server**.\n2. **Server name:** `Oneteam`. **Server URL:** `https://api.oneteam.io/mcp`\n (a Oneteam test environment: its API URL followed by `/mcp`).\n3. Keep **OAuth**, click **Add & authorize** and sign in with Oneteam as an\n organisation admin.\n\nThen call `get_app_setup_guide`. In Claude, Cursor and other MCP clients the\nconnector is added the same way, with the same URL.\n\nNever ask the user for Oneteam secrets before `register_app` has returned them:\nthey don't exist until the app is registered, and registering needs the app's\npublished URL.\n\n## Never hand-roll this\n\nWriting the OAuth flow by hand is the main way apps get this wrong: PKCE with\n`plain`, no `state` check, an ID token read with `atob` instead of verified,\ntokens in `localStorage`. Use the package, which does the whole flow in one\ncall, or stop and say it isn't supported on this stack.\n\n## Rules\n\nThese are not style preferences. Breaking one creates a security hole.\n\n- **Deny by default.** Next.js: `proxy.ts` runs `oneteam.proxy()` for every route.\n TanStack Start: `src/start.ts` has a request middleware that calls\n `oneteam.guard()`. Add to `publicPaths` only what anyone on the internet may\n open (health checks, a public landing page), and ask the user first.\n- **Never exclude routes from the gate by file extension.** `/api/receipts/1.jpg`\n is data. Keep the scaffolded matcher; put public files in `public/assets/`\n and add `'/assets/*'` to `publicPaths`.\n- **The gate is a safety net, not the check.** Server actions, server functions\n and route handlers still call `requirePermission` / `protectRoute` themselves.\n- **Check permissions on the server, every time.** Everything that reads or\n changes protected data runs on the server (Next.js server action or route\n handler, TanStack Start server function or server route) and calls\n `oneteam.requirePermission(...)` (or uses `oneteam.protectRoute`). `<Protected>`\n and `useOneteam().can` only hide UI.\n- **Never trust ids from the browser for authorization.** Load the record\n first, then check against *its* community/owner:\n `requirePermission('network::users.update', { communityId: expense.communityId })`.\n Scope list queries with `oneteam.communitiesFor(session, permission)`.\n- **Identify users by `session.user.id`** (Oneteam user id) or\n `session.user.externalId` (employee number). Not by email, not by `sub`,\n never by a user id sent in a request body.\n- **Secrets stay on the server.** Never prefix `ONETEAM_CLIENT_SECRET` or\n `ONETEAM_SESSION_SECRET` with `NEXT_PUBLIC_` or `VITE_`, never put them in code,\n never log tokens or sessions, never pass `getAccessToken()` results to the browser.\n TanStack Start: import `oneteam.server.ts` only from server functions, server\n routes and `src/start.ts`.\n- **Don't build your own login.** No custom JWT parsing, no reading tokens from\n query strings, no localStorage tokens, no second login screen.\n- **Authorize on Oneteam permissions only.** Not on a role, an admin flag, a\n function name, an email domain or a hard-coded list of user ids. Pick each\n permission from `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`\n (online: https://unpkg.com/@oneteam-app/apps/docs/oneteam-permissions.md)\n (typed as `OneteamPermission`, so a wrong name doesn't compile) and name the\n ones the app uses once, e.g. in `lib/permissions.ts`. Never invent a name or\n match on a prefix.\n- **Community permissions are not organisation-wide.** A `network:` permission\n without `communityId` is only true for users who hold the same action\n organisation-wide. That's intended: don't \"fix\" it by dropping the community check.\n\n## The shape of a protected app\n\nTanStack Start (Lovable): full files in [references/tanstack-start.md](references/tanstack-start.md).\n\n```ts\n// src/lib/oneteam.server.ts\nimport { createOneteam } from '@oneteam-app/apps/tanstack-start';\nimport manifest from '../../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// src/routes/api/oneteam/$.ts\nexport const Route = createFileRoute('/api/oneteam/$')({ server: { handlers: oneteam.handlers } });\n\n// src/start.ts \u2014 deny by default\nconst oneteamMiddleware = createMiddleware().server((ctx) => oneteam.guard(ctx, { publicPaths: ['/health'] }));\nexport const startInstance = createStart(() => ({ requestMiddleware: [oneteamMiddleware] }));\n\n// any server function that touches protected data\nexport const approveExpense = createServerFn({ method: 'POST' })\n .validator((id: string) => id)\n .handler(async ({ data: id }) => {\n const expense = await db.expense.findUniqueOrThrow({ where: { id } });\n await oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n // ...\n });\n```\n\nNext.js:\n\n```ts\n// lib/oneteam.ts\nimport { createOneteam } from '@oneteam-app/apps/next';\nimport manifest from '../oneteam.app.json';\nexport const oneteam = createOneteam({ scopes: manifest.scopes });\n\n// proxy.ts \u2014 deny by default\nexport const proxy = oneteam.proxy({ publicPaths: ['/health'] });\n\n// app/api/oneteam/[...oneteam]/route.ts\nexport const { GET, POST } = oneteam.handlers;\n\n// lib/permissions.ts \u2014 the Oneteam permissions this app checks\nexport const APPROVE_EXPENSES = 'network::users.update' satisfies OneteamPermission;\n\n// any server action or route handler that touches protected data\nconst expense = await db.expense.findUniqueOrThrow({ where: { id } });\nawait oneteam.requirePermission(APPROVE_EXPENSES, { communityId: expense.communityId });\n```\n";
561
+ var HOSTED_AGENT_INSTRUCTIONS = '# Build an app that Oneteam hosts\n\nA hosted app is a small internal tool for one organisation: a web front end, an\nAPI and its own database. Oneteam runs it, signs employees in before a request\nreaches it, reviews every upload, and shows it to employees as a shortcut once\nan admin has approved it. There is no server to set up and no login to build,\nand the app holds no credentials: its database needs none, and a key for\nanother service is entered by a person, never written into the app.\n\n**What fits:** forms and logs (lost and found, incident reports, key handover),\nchecklists, request and approval flows, small planners, lookups and dashboards\non data the app itself collects, for tens to thousands of colleagues. A\nreminder, a push notification when something happens, and work that runs on a\nschedule (every 15 minutes at most) fit too: see "Scheduled jobs and sending".\n\n**What does not fit, say so instead of building around it:** file uploads and\nimage storage (not available yet), email and SMS, chat messages from the app\n(declared in the manifest already, but the platform cannot send them yet),\nwork that has to run more often than every 15 minutes or longer than 30\nseconds, real-time collaboration, reading or changing Oneteam\'s own\ndata (messages, schedules, documents), anything for people outside the\norganisation, and apps that need a server-rendered framework.\n\n## Three ways to build, and which to pick\n\nRecommend the first unless the user has a reason it cannot work. Say what the\nother two give up before going there.\n\n| | What you get | What you give up |\n|---|---|---|\n| **Hosted here** (these instructions) | Sign-in, a database, a review of every upload, an admin\'s approval, a preview next to live, secrets nobody can read back, request logs. One command to deploy | The stack is fixed (Vite + React, Hono, the app\'s own database), and what is in the "does not fit" list |\n| **Hosted elsewhere, Oneteam sign-in only** (the `oneteam-auth` instructions) | Any Next.js or TanStack Start app on the user\'s own hosting, opened from a shortcut with the employee signed in | Everything else is the user\'s to run and secure: hosting, the database and who can reach it, the client secret and session secret, backups, updates. Nobody reviews the code before colleagues use it |\n| **Code on GitHub, deployed from CI** | The same hosted app as the first row, with the code in the user\'s repository and `oneteam-apps deploy` run by a workflow | A personal access token has to live in the CI\'s secret store (`ONETEAM_TOKEN`), and whoever can change the workflow can deploy. Secrets of the app are still entered by a person, not by CI |\n\nThe third is the first with a different way of running `deploy`; nothing in\nthe app changes. Start with the first and move the deploy into CI later if the\nuser wants reviews on pull requests.\n\n## The shape of a hosted app\n\nAlways start from the template. Do not assemble this by hand and do not switch\nstack: the platform runs exactly this shape.\n\n```bash\nnpx @oneteam-app/apps-cli init --hosted "Lost and found"\ncd lost-and-found && npm install\nnpm run dev # the app with a "View as" bar instead of sign-on; no network needed\n```\n\n| | |\n|---|---|\n| `src/worker/index.ts` | The API, with Hono, under `/api`. One Worker serves the API and the front end |\n| `src/worker/items.ts` | All database access for one kind of record |\n| `src/worker/index.test.ts` | Who can see and do what, tested with fake users |\n| `src/worker/jobs.ts` | Jobs the platform runs on a schedule (`defineJobs`). Empty to start with |\n| `src/worker/dev.ts` | Local stand-in for sign-on. Never import it, or `@oneteam-app/apps/testing`, from the API |\n| `src/client/` | The front end: Vite + React, built from the components of `@oneteam-app/ui` |\n| `migrations/NNNN_name.sql` | The database schema, applied in order. Add a new file; never edit one that was deployed |\n| `oneteam.app.json` | Name, address (`slug`), what changed (`summary`), personal data stored, who should see it, hosts it calls, the names of its secrets |\n| `.dev.vars.example` | The names of the secrets, for running locally. `.dev.vars` (the values) is git-ignored and never uploaded |\n\nThe template is a working app with one example feature (a lost and found log).\nReplace the feature, keep the structure.\n\n## Rules\n\nThese are not style preferences. The review stops a deploy that breaks them,\nand each one is a way real data leaks.\n\n- **Who is asking comes from the session, never from the request.** Every route\n under `/api` runs after `api.use(oneteam())` and reads `c.get(\'session\')`.\n Identify people by `session.user.id`. Never accept a user id, community id or\n role from the body, the query string or a header as proof of anything.\n- **The database is `env.DB`, and only in the API.** No connection strings, no\n other database, no database calls from the front end. The front end talks to\n `/api`; `/api` talks to `env.DB`.\n- **Parameterised SQL only.** Values go in `.bind(...)`, never into the SQL\n text: `env.DB.prepare(\'SELECT \u2026 WHERE id = ?\').bind(id)`. No template\n literals or string concatenation with values, not for `ORDER BY`, not for\n `IN (...)` lists (generate the `?` placeholders, bind the values), not "just\n for numbers".\n- **Every query is limited to what this user may see.** Each table that holds\n records has a `community_id` (and a `user_id` where records are personal),\n and every `SELECT`, `UPDATE` and `DELETE` filters on it with values from the\n session: the user\'s own communities (`session.user.communities`), or the\n communities where they hold a permission (`communitiesFor(session, permission)`).\n A query without such a filter returns other people\'s data.\n- **Every write checks a permission.** Before an `INSERT`, `UPDATE` or `DELETE`:\n load the record with the user\'s scope, then `assertCan(session, permission,\n { communityId: record.communityId })` for anything not every member may do.\n A record outside the user\'s scope is `404`, not `403`. For a new record, the\n community must be one of the user\'s own.\n- **Permissions are Oneteam permissions.** Pick each one from\n `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`; its "Granted by"\n column is the role editor toggle admins use. Never authorize on a role name,\n a function name, an email address or a hard-coded list of ids, and never\n invent a permission. `can()` in the front end only hides buttons.\n- **The app does not live at `/`.** It is served at `/<organisation>/<app>/`\n and the same build at `/<organisation>/<app>--preview/`. Keep `base: \'./\'` in\n `vite.config.ts` and the `<base>` tag in `index.html`; call the API with\n `apiJson(\'/api/\u2026\')` or `api()` from `@oneteam-app/apps/hosted/react`, never\n `fetch(\'/api/\u2026\')`; give a router `basePath()` as its base; write links and\n asset URLs relative (`./logo.svg`), never starting with `/`.\n- **No other services by default.** The app\'s own database is enough for most\n apps. Every host the Worker calls must be listed in `outbound_hosts` in\n `oneteam.app.json`; ask the user before adding one.\n- **A secret is a name, never a value.** When the app needs a key for another\n service, list its name under `secrets` in `oneteam.app.json`\n (`{ "name": "SUPABASE_SERVICE_KEY", "host": "xyz.supabase.co" }`), add it to\n `Env`, and read it in the Worker with `secret(c.env, \'SUPABASE_SERVICE_KEY\')`\n from `@oneteam-app/apps/hosted`. That is all you do with it.\n - **Never ask the person for the value**, and never accept one: if they paste\n a key into the chat, tell them it is now exposed and should be replaced at\n its source, and do not use it.\n - **Never write a value** into code, a config file, `oneteam.app.json`, a\n test, a commit message, a command line or the chat. Not a "temporary" one,\n not base64, not split in two.\n - **The person enters it**, once for preview and once for live: in the\n dashboard (the app\'s Secrets tab) or with\n `npx oneteam-apps secret set NAME --env preview`, which asks at a hidden\n prompt. Tell them to run it themselves; do not run it for them and do not\n pipe anything into it. After saving, nobody can read the value back.\n - **Locally** the values are in `.dev.vars`, which the person fills in\n themselves. Never read, print, create or edit `.dev.vars` or `.env` files;\n `.dev.vars.example` (names only) is yours to keep up to date.\n - **Never in the browser.** No secret in `src/client/`, no `VITE_` name for\n one, no route that returns one. The Worker makes the call and returns the result.\n- **An outside backend (Supabase and the like) stays behind the Worker.** Use\n it only when the app\'s own database cannot do the job, and say what that\n gives up: the review cannot see who may read those tables. Then:\n the service key is a secret and lives in the Worker only; the front end never\n talks to Supabase directly and never gets a key, also not the "anon" one;\n turn on row-level security on every table with no policy for anonymous\n access, so a leaked URL is not a leaked database; declare the host in\n `outbound_hosts` and as the secret\'s `host`; and keep every rule above, since\n the Worker is still the only thing that decides who sees which row.\n- **Scheduled jobs and sending are declared, never keyed.** A reminder, a\n notification or a recurring job needs no cron service, no API key, no\n service account and no webhook: never create, ask for or suggest one.\n - **Declare it** in `oneteam.app.json`:\n `"schedules": [{ "name": "send-reminders", "every": "day", "at": "07:00" }]`\n and `"sends": ["notifications"]`. `every` is `15_minutes`, `30_minutes`,\n `hour` (with `minute`), `day`, `week` (with `weekdays`) or `month` (with\n `day_of_month`, 1 to 28); `at` is `HH:MM`. Not cron syntax.\n - **Write the handler** under the same name in `src/worker/jobs.ts`:\n `defineJobs<Env>({ \'send-reminders\': async (job, env) => { \u2026 } })`. Every\n schedule needs a handler and every handler a schedule; `deploy` refuses\n otherwise.\n - **Send with `notify(from, { userIds, title, body, path })`** from\n `@oneteam-app/apps/hosted`, where `from` is the job (`notify(job, \u2026)`) or\n the signed-in person\'s request (`notify(c.req.raw, \u2026)`). Title at most 80\n characters, body at most 240, `path` a path inside the app.\n - **Nobody is signed in during a job.** There is no session: decide who a\n job acts for from the app\'s own data (the `user_id` stored with a record),\n never from anything else.\n - **An admin approves it.** A new job, one that runs more often, and\n anything new under `sends` wait for an admin before they start; say so\n when you hand over. The live app keeps running as it was until then.\n - **Recipients outside the app\'s audience are skipped**, as is anyone who\n already got five notifications from this app today (1,000 a day for the\n whole app). `notify` returns `{ sent, skipped }`; a skip is not an error,\n so do not retry it.\n - **Keep a job short and safe to run twice.** It has 30 seconds. A run that\n is missed is skipped, not caught up, and an admin can start a run by hand:\n record what was done (a `reminded_at` column) so nobody is told twice, and\n work through a bounded number of records per run.\n- **Say what personal data is stored.** List it in `personal_data` in plain\n words ("name of the employee who logged an item"). Store the least that\n works: a user id and a display name, not an email address or phone number\n unless the feature needs it.\n\n## Design\n\n- **Build every screen from `@oneteam-app/ui`.** `Page`, `ActionBar`, `List`,\n `ListItem`, `Card`, `Button`, `Field`, `TextInput`, `Select`, `Tabs`, `Badge`,\n `ConfirmDialog`, `Sheet`, `toast` and the rest are listed with their props in\n `node_modules/@oneteam-app/ui/docs/components.md`\n ([references/ui-components.md](references/ui-components.md)): read it before\n writing a screen. Never write your own `<button>`, `<input>`, `<select>`,\n `<textarea>`, dialog, list row or loading state, and never call `confirm()`\n or `alert()`: the components carry the touch sizes, focus states, labels and\n screen-reader wiring, and they are what makes the app look like the rest.\n If something is not there, compose it from what is, or tell the user.\n- **Style with Tailwind, and only with Tailwind.** It is set up in the template\n and wired to the theme. Layout and spacing are Tailwind classes in the markup\n (`flex`, `grid`, `gap-4`, `p-4`) around the components. No `.css` files of\n your own (`src/client/styles.css` stays the two imports it starts with), no\n inline `style` for anything a class can do.\n- **No colours of your own.** The app is wrapped once in\n `<OneteamTheme theme={themeOf(me)}>`, which applies the organisation\'s colours\n and logo, light and dark mode. Where a class needs a colour, use the theme\'s\n names: `bg-card`, `bg-muted`, `text-muted-foreground`, `bg-primary`,\n `text-destructive`, `border-border`. Never a palette colour (`bg-blue-500`,\n `text-white`), an arbitrary one (`bg-[#123456]`), a colour in an inline\n `style`, a hex code or `rgb(\u2026)`.\n- **Phone first, responsive by construction.** Most colleagues open the app on\n their phone, inside the Oneteam app. Write the classes without a prefix for a\n 375 px wide screen: one column, full width. Add `sm:`, `md:` or `lg:` only to\n let the same screen breathe on a tablet or desktop: `grid gap-6\n md:grid-cols-2`, and `<Page width="wide">` for a screen with two columns.\n Never a fixed pixel width, never a layout that only works wide. The component\n reference has three layouts to copy: a list with the action at the bottom, a\n form, and a detail screen that becomes two columns.\n- **One primary action per screen**, in an `<ActionBar>` in the `Page`\'s\n `footer`, where a thumb reaches it. Other actions are `secondary` or `quiet`.\n A form is its own screen, with its submit button in that bar.\n- **Plain words.** Say "store", "colleague", "returned": the words the\n organisation uses. No product names, no jargon, no "Oneteam" in the interface.\n- **Every state has a screen:** `ListSkeleton` while loading, `EmptyState`\n ("Nothing waiting to be picked up"), `ErrorState` with a way to try again,\n `NotAllowedState`, a `Field`\'s `error`, and a `toast` when something worked.\n\n## Workflow\n\nDo these in order.\n\n1. **Understand what to build.** Who uses it, what do they record or look up,\n who may do what, and per what (store, team, the whole organisation)? If it is\n in the "does not fit" list, say so now.\n2. **Start from the template** (see above). If the folder already holds a hosted\n app (`oneteam.app.json` with `"hosting": "hosted"`), work in it.\n3. **Model the data.** One migration per change in `migrations/`, named\n `0002_add_reports.sql` and so on. Every table with records gets\n `community_id TEXT NOT NULL` and an index on it. Ids from Oneteam (users,\n communities) are stored as `TEXT`.\n4. **Build the API** in `src/worker/`, one file of queries per kind of record,\n following the rules above. Return only the fields the front end shows.\n5. **Choose the permissions.** For each action not every member may do, pick\n the Oneteam permission whose holders should be allowed, and name it once in\n `src/shared.ts`. Tell the user which ones you chose and why.\n6. **Build the front end** in `src/client/` from the components of\n `@oneteam-app/ui`, following the design rules. `npm run dev` has a "Button colour"\n box in the bar at the top: try a pale colour and a dark one, look at the app\n in dark mode, and at 375 px wide as well as on a full desktop window.\n7. **Write the permission tests, and run them.** In `src/worker/index.test.ts`,\n with `withFakeContext` from `@oneteam-app/apps/testing`, for every route that\n reads or writes records:\n - a request without a session is refused (401);\n - a member of another community does not see the records, and gets 404 when\n addressing one directly;\n - someone without the permission is refused (403), someone with it in\n *another* community is refused too, someone with it in the record\'s\n community succeeds.\n ```bash\n npm test && npm run typecheck\n ```\n Do not deploy with a failing or skipped test.\n8. **Fill in `oneteam.app.json`:** `name`, `description`, `summary` (what\n changed in this version, one or two sentences an admin understands),\n `personal_data`, `outbound_hosts`, and `secrets` (names only) when the app\n needs any. Then `npx oneteam-apps validate` and `npx oneteam-apps doctor`:\n the second fails when an env file is not git-ignored or a key is written\n into the code, which `deploy` would refuse as well. It also warns about\n colours written out and hand-made buttons, inputs and dialogs in\n `src/client`, and about a `.css` file of your own: fix those too, by using\n the component, a Tailwind class or the theme\'s colour name.\n9. **Ask who should see the app.** Never guess the audience. Ask the user:\n everyone in the organisation, or certain communities or functions? Put the\n answer in `suggested_audience.description` in their words, and the ids in\n `network_ids` / `function_ids` when you know them (the Oneteam connector\'s\n `get_organisation_structure` lists them). It is a suggestion: the admin who\n approves the app decides.\n10. **Deploy.**\n ```bash\n npx oneteam-apps deploy\n ```\n The first time it needs to know who you are and which organisation the app\n is for: `npx oneteam-apps login` (or `ONETEAM_TOKEN`), and\n `--organisation <id>` (ask the user; the Oneteam connector\'s\n `list_my_organisations` lists them). It builds the app, uploads it and\n prints the review.\n11. **Read the findings and fix them.** Exit code 1 and "Deploy stopped" mean\n nothing was deployed. Each finding is printed as\n `SEVERITY file:line`, what is wrong, and how to fix it. Fix the cause in\n the source (not the symptom the review happened to notice: if one query is\n unscoped, check the others), run the tests, deploy again. Never work around\n a finding by hiding the code from the review. If you believe a finding is\n wrong, tell the user and let them decide. "Not uploaded: the app would\n carry secrets with it" means a key is in the code or an env file is not\n ignored: take it out as the message says; the key itself has to be replaced\n by the user where it came from.\n When the app has jobs or sends something, the output also lists what an\n admin has to approve before it starts. Try a job in the preview first:\n `npx oneteam-apps job run <name> --env preview`, then\n `npx oneteam-apps job list` for how it went. In `npm run dev`, "Run job"\n in the bar at the top does the same, and what the app sends is printed in\n the terminal.\n12. **Hand over.** Exit code 0 prints the preview address. Give it to the user\n and tell them plainly what happens next: they (and testers an admin\n invites) can open the preview now; an organisation admin has to approve the\n version in the dashboard before colleagues see it; after that it appears as\n a shortcut for the audience the admin chose. When the output lists\n "Secrets that still need a value", pass that on word for word: which names,\n for preview and for live, and that they enter them in the dashboard\'s\n Secrets tab or with `npx oneteam-apps secret set NAME --env preview` (and\n `--env live`). The preview does not fully work, and the version cannot be\n approved, until they have.\n\nAfter that: `npx oneteam-apps status` shows where the app stands (which\nsecrets still need a value, which jobs run when and how the last run went), `npx oneteam-apps secret list` shows every secret\nand whether it has a value per environment (never the value), and\n`npx oneteam-apps logs` shows recent errors and requests of the running app\n(`--env preview`, `--hours 2`, `--json`). A new `deploy` creates a new version\nthat goes through review and approval again; the live version keeps running\nuntil then.\n\n## What you cannot do for the user\n\nSay these plainly instead of reporting success:\n\n- Approving the app, choosing its audience and inviting testers: an\n organisation admin does that in the dashboard.\n- Giving people the Oneteam permissions the app checks: admins do that through\n roles.\n- Signing in for the user. `oneteam-apps login` opens their browser; without\n one, they create a personal access token in their Oneteam profile and set it\n as `ONETEAM_TOKEN` themselves. Never ask them to paste a token into the chat.\n- Approving a scheduled job or what the app sends: an admin does that with the\n version. You declare it and say what is waiting for approval.\n- Entering a secret\'s value. Only a person does that, in the dashboard\'s\n Secrets tab or at the hidden prompt of `oneteam-apps secret set`. You declare\n the name and say which values are still needed; you never see, ask for, type\n or pass on a value, and there is no tool or connector that sets one for you.\n\n## Reference\n\n- `README.md` in the app: where everything lives\n- `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`\n ([references/oneteam-permissions.md](references/oneteam-permissions.md)): every Oneteam permission name\n- Server API (`@oneteam-app/apps/hosted`): `oneteam()` (Hono middleware),\n `getSession(request)`, `can`, `assertCan`, `communitiesFor`,\n `toClientSession(session)` (for `GET /api/me`), `secret(env, name)` (a\n declared secret; throws when it has no value), `errorResponse(error)` (401 /\n 403 / 503 in `app.onError`). The session has `user.id`, `user.name`,\n `user.externalId`, `user.communities`, `user.functions`, `basePath`, `env`.\n- Jobs and sending (`@oneteam-app/apps/hosted`): `defineJobs({ name: (job, env) => \u2026 })`,\n `withJobs(app, jobs)` as the Worker\'s default export, `notify(from, \u2026)`,\n `sendChatMessage(from, \u2026)` (not available on the platform yet),\n `OneteamSendError` (`code`: `not_allowed`, `not_available`, `invalid`,\n `no_caller`, `failed`). In tests: `fakeJobRequest(name)` and `fakeSending()`\n from `@oneteam-app/apps/testing`.\n- Front end (`@oneteam-app/apps/hosted/react`): `useMe()`, `apiJson()`, `api()`,\n `apiUrl()`, `basePath()`, `can()`, `themeOf(me)`.\n- Components (`@oneteam-app/ui`): `node_modules/@oneteam-app/ui/docs/components.md`\n ([references/ui-components.md](references/ui-components.md)).\n- Tests (`@oneteam-app/apps/testing`): `withFakeContext(path, user, init)`,\n `fakeContext(user)`.';
562
+ var HOSTED_CURSOR_RULE = '---\ndescription: Rules for this app, which Oneteam hosts\nalwaysApply: true\n---\n\n## The shape of a hosted app\n\nAlways start from the template. Do not assemble this by hand and do not switch\nstack: the platform runs exactly this shape.\n\n```bash\nnpx @oneteam-app/apps-cli init --hosted "Lost and found"\ncd lost-and-found && npm install\nnpm run dev # the app with a "View as" bar instead of sign-on; no network needed\n```\n\n| | |\n|---|---|\n| `src/worker/index.ts` | The API, with Hono, under `/api`. One Worker serves the API and the front end |\n| `src/worker/items.ts` | All database access for one kind of record |\n| `src/worker/index.test.ts` | Who can see and do what, tested with fake users |\n| `src/worker/jobs.ts` | Jobs the platform runs on a schedule (`defineJobs`). Empty to start with |\n| `src/worker/dev.ts` | Local stand-in for sign-on. Never import it, or `@oneteam-app/apps/testing`, from the API |\n| `src/client/` | The front end: Vite + React, built from the components of `@oneteam-app/ui` |\n| `migrations/NNNN_name.sql` | The database schema, applied in order. Add a new file; never edit one that was deployed |\n| `oneteam.app.json` | Name, address (`slug`), what changed (`summary`), personal data stored, who should see it, hosts it calls, the names of its secrets |\n| `.dev.vars.example` | The names of the secrets, for running locally. `.dev.vars` (the values) is git-ignored and never uploaded |\n\nThe template is a working app with one example feature (a lost and found log).\nReplace the feature, keep the structure.\n\n## Rules\n\nThese are not style preferences. The review stops a deploy that breaks them,\nand each one is a way real data leaks.\n\n- **Who is asking comes from the session, never from the request.** Every route\n under `/api` runs after `api.use(oneteam())` and reads `c.get(\'session\')`.\n Identify people by `session.user.id`. Never accept a user id, community id or\n role from the body, the query string or a header as proof of anything.\n- **The database is `env.DB`, and only in the API.** No connection strings, no\n other database, no database calls from the front end. The front end talks to\n `/api`; `/api` talks to `env.DB`.\n- **Parameterised SQL only.** Values go in `.bind(...)`, never into the SQL\n text: `env.DB.prepare(\'SELECT \u2026 WHERE id = ?\').bind(id)`. No template\n literals or string concatenation with values, not for `ORDER BY`, not for\n `IN (...)` lists (generate the `?` placeholders, bind the values), not "just\n for numbers".\n- **Every query is limited to what this user may see.** Each table that holds\n records has a `community_id` (and a `user_id` where records are personal),\n and every `SELECT`, `UPDATE` and `DELETE` filters on it with values from the\n session: the user\'s own communities (`session.user.communities`), or the\n communities where they hold a permission (`communitiesFor(session, permission)`).\n A query without such a filter returns other people\'s data.\n- **Every write checks a permission.** Before an `INSERT`, `UPDATE` or `DELETE`:\n load the record with the user\'s scope, then `assertCan(session, permission,\n { communityId: record.communityId })` for anything not every member may do.\n A record outside the user\'s scope is `404`, not `403`. For a new record, the\n community must be one of the user\'s own.\n- **Permissions are Oneteam permissions.** Pick each one from\n `node_modules/@oneteam-app/apps/docs/oneteam-permissions.md`; its "Granted by"\n column is the role editor toggle admins use. Never authorize on a role name,\n a function name, an email address or a hard-coded list of ids, and never\n invent a permission. `can()` in the front end only hides buttons.\n- **The app does not live at `/`.** It is served at `/<organisation>/<app>/`\n and the same build at `/<organisation>/<app>--preview/`. Keep `base: \'./\'` in\n `vite.config.ts` and the `<base>` tag in `index.html`; call the API with\n `apiJson(\'/api/\u2026\')` or `api()` from `@oneteam-app/apps/hosted/react`, never\n `fetch(\'/api/\u2026\')`; give a router `basePath()` as its base; write links and\n asset URLs relative (`./logo.svg`), never starting with `/`.\n- **No other services by default.** The app\'s own database is enough for most\n apps. Every host the Worker calls must be listed in `outbound_hosts` in\n `oneteam.app.json`; ask the user before adding one.\n- **A secret is a name, never a value.** When the app needs a key for another\n service, list its name under `secrets` in `oneteam.app.json`\n (`{ "name": "SUPABASE_SERVICE_KEY", "host": "xyz.supabase.co" }`), add it to\n `Env`, and read it in the Worker with `secret(c.env, \'SUPABASE_SERVICE_KEY\')`\n from `@oneteam-app/apps/hosted`. That is all you do with it.\n - **Never ask the person for the value**, and never accept one: if they paste\n a key into the chat, tell them it is now exposed and should be replaced at\n its source, and do not use it.\n - **Never write a value** into code, a config file, `oneteam.app.json`, a\n test, a commit message, a command line or the chat. Not a "temporary" one,\n not base64, not split in two.\n - **The person enters it**, once for preview and once for live: in the\n dashboard (the app\'s Secrets tab) or with\n `npx oneteam-apps secret set NAME --env preview`, which asks at a hidden\n prompt. Tell them to run it themselves; do not run it for them and do not\n pipe anything into it. After saving, nobody can read the value back.\n - **Locally** the values are in `.dev.vars`, which the person fills in\n themselves. Never read, print, create or edit `.dev.vars` or `.env` files;\n `.dev.vars.example` (names only) is yours to keep up to date.\n - **Never in the browser.** No secret in `src/client/`, no `VITE_` name for\n one, no route that returns one. The Worker makes the call and returns the result.\n- **An outside backend (Supabase and the like) stays behind the Worker.** Use\n it only when the app\'s own database cannot do the job, and say what that\n gives up: the review cannot see who may read those tables. Then:\n the service key is a secret and lives in the Worker only; the front end never\n talks to Supabase directly and never gets a key, also not the "anon" one;\n turn on row-level security on every table with no policy for anonymous\n access, so a leaked URL is not a leaked database; declare the host in\n `outbound_hosts` and as the secret\'s `host`; and keep every rule above, since\n the Worker is still the only thing that decides who sees which row.\n- **Scheduled jobs and sending are declared, never keyed.** A reminder, a\n notification or a recurring job needs no cron service, no API key, no\n service account and no webhook: never create, ask for or suggest one.\n - **Declare it** in `oneteam.app.json`:\n `"schedules": [{ "name": "send-reminders", "every": "day", "at": "07:00" }]`\n and `"sends": ["notifications"]`. `every` is `15_minutes`, `30_minutes`,\n `hour` (with `minute`), `day`, `week` (with `weekdays`) or `month` (with\n `day_of_month`, 1 to 28); `at` is `HH:MM`. Not cron syntax.\n - **Write the handler** under the same name in `src/worker/jobs.ts`:\n `defineJobs<Env>({ \'send-reminders\': async (job, env) => { \u2026 } })`. Every\n schedule needs a handler and every handler a schedule; `deploy` refuses\n otherwise.\n - **Send with `notify(from, { userIds, title, body, path })`** from\n `@oneteam-app/apps/hosted`, where `from` is the job (`notify(job, \u2026)`) or\n the signed-in person\'s request (`notify(c.req.raw, \u2026)`). Title at most 80\n characters, body at most 240, `path` a path inside the app.\n - **Nobody is signed in during a job.** There is no session: decide who a\n job acts for from the app\'s own data (the `user_id` stored with a record),\n never from anything else.\n - **An admin approves it.** A new job, one that runs more often, and\n anything new under `sends` wait for an admin before they start; say so\n when you hand over. The live app keeps running as it was until then.\n - **Recipients outside the app\'s audience are skipped**, as is anyone who\n already got five notifications from this app today (1,000 a day for the\n whole app). `notify` returns `{ sent, skipped }`; a skip is not an error,\n so do not retry it.\n - **Keep a job short and safe to run twice.** It has 30 seconds. A run that\n is missed is skipped, not caught up, and an admin can start a run by hand:\n record what was done (a `reminded_at` column) so nobody is told twice, and\n work through a bounded number of records per run.\n- **Say what personal data is stored.** List it in `personal_data` in plain\n words ("name of the employee who logged an item"). Store the least that\n works: a user id and a display name, not an email address or phone number\n unless the feature needs it.\n\n## Design\n\n- **Build every screen from `@oneteam-app/ui`.** `Page`, `ActionBar`, `List`,\n `ListItem`, `Card`, `Button`, `Field`, `TextInput`, `Select`, `Tabs`, `Badge`,\n `ConfirmDialog`, `Sheet`, `toast` and the rest are listed with their props in\n `node_modules/@oneteam-app/ui/docs/components.md`\n ([references/ui-components.md](references/ui-components.md)): read it before\n writing a screen. Never write your own `<button>`, `<input>`, `<select>`,\n `<textarea>`, dialog, list row or loading state, and never call `confirm()`\n or `alert()`: the components carry the touch sizes, focus states, labels and\n screen-reader wiring, and they are what makes the app look like the rest.\n If something is not there, compose it from what is, or tell the user.\n- **Style with Tailwind, and only with Tailwind.** It is set up in the template\n and wired to the theme. Layout and spacing are Tailwind classes in the markup\n (`flex`, `grid`, `gap-4`, `p-4`) around the components. No `.css` files of\n your own (`src/client/styles.css` stays the two imports it starts with), no\n inline `style` for anything a class can do.\n- **No colours of your own.** The app is wrapped once in\n `<OneteamTheme theme={themeOf(me)}>`, which applies the organisation\'s colours\n and logo, light and dark mode. Where a class needs a colour, use the theme\'s\n names: `bg-card`, `bg-muted`, `text-muted-foreground`, `bg-primary`,\n `text-destructive`, `border-border`. Never a palette colour (`bg-blue-500`,\n `text-white`), an arbitrary one (`bg-[#123456]`), a colour in an inline\n `style`, a hex code or `rgb(\u2026)`.\n- **Phone first, responsive by construction.** Most colleagues open the app on\n their phone, inside the Oneteam app. Write the classes without a prefix for a\n 375 px wide screen: one column, full width. Add `sm:`, `md:` or `lg:` only to\n let the same screen breathe on a tablet or desktop: `grid gap-6\n md:grid-cols-2`, and `<Page width="wide">` for a screen with two columns.\n Never a fixed pixel width, never a layout that only works wide. The component\n reference has three layouts to copy: a list with the action at the bottom, a\n form, and a detail screen that becomes two columns.\n- **One primary action per screen**, in an `<ActionBar>` in the `Page`\'s\n `footer`, where a thumb reaches it. Other actions are `secondary` or `quiet`.\n A form is its own screen, with its submit button in that bar.\n- **Plain words.** Say "store", "colleague", "returned": the words the\n organisation uses. No product names, no jargon, no "Oneteam" in the interface.\n- **Every state has a screen:** `ListSkeleton` while loading, `EmptyState`\n ("Nothing waiting to be picked up"), `ErrorState` with a way to try again,\n `NotAllowedState`, a `Field`\'s `error`, and a `toast` when something worked.\n';
14
563
 
15
564
  // src/templates.ts
16
565
  var ENV_KEYS = [
@@ -115,14 +664,14 @@ export const fetchSession = createServerFn({ method: 'GET' }).handler(() => onet
115
664
  // src/commands.ts
116
665
  var MANIFEST_FILE = "oneteam.app.json";
117
666
  function readJson(path) {
118
- return JSON.parse(readFileSync(path, "utf8"));
667
+ return JSON.parse(readFileSync4(path, "utf8"));
119
668
  }
120
669
  function writeProjectFile(root, path, contents) {
121
- const absolute = join(root, path);
670
+ const absolute = join4(root, path);
122
671
  const realRoot = realpathSync(root);
123
- const isInside = (target) => target === realRoot || target.startsWith(`${realRoot}${sep}`);
672
+ const isInside = (target3) => target3 === realRoot || target3.startsWith(`${realRoot}${sep2}`);
124
673
  let existingParent = dirname(absolute);
125
- while (!existsSync(existingParent)) existingParent = dirname(existingParent);
674
+ while (!existsSync4(existingParent)) existingParent = dirname(existingParent);
126
675
  if (!isInside(realpathSync(existingParent))) {
127
676
  throw new Error(`refusing to write ${path}: it resolves outside the project`);
128
677
  }
@@ -130,14 +679,14 @@ function writeProjectFile(root, path, contents) {
130
679
  if (!isInside(realpathSync(dirname(absolute)))) {
131
680
  throw new Error(`refusing to write ${path}: it resolves outside the project`);
132
681
  }
133
- if (existsSync(absolute) && lstatSync(absolute).isSymbolicLink()) {
682
+ if (existsSync4(absolute) && lstatSync2(absolute).isSymbolicLink()) {
134
683
  throw new Error(`refusing to write ${path}: it is a symbolic link`);
135
684
  }
136
685
  writeFileSync(absolute, contents);
137
686
  }
138
687
  function writeIfMissing(root, path, contents, out2, force) {
139
- const absolute = join(root, path);
140
- if (existsSync(absolute) && !force) {
688
+ const absolute = join4(root, path);
689
+ if (existsSync4(absolute) && !force) {
141
690
  out2.warn(`skipped ${path} (already exists; use --force to overwrite)`);
142
691
  return false;
143
692
  }
@@ -146,33 +695,33 @@ function writeIfMissing(root, path, contents, out2, force) {
146
695
  return true;
147
696
  }
148
697
  function detectNextApp(root) {
149
- const packagePath = join(root, "package.json");
150
- if (!existsSync(packagePath)) return null;
698
+ const packagePath = join4(root, "package.json");
699
+ if (!existsSync4(packagePath)) return null;
151
700
  const pkg = readJson(packagePath);
152
701
  if (!pkg.dependencies?.next && !pkg.devDependencies?.next) return null;
153
- if (existsSync(join(root, "src", "app"))) return { appDir: "src/app", srcPrefix: "src/" };
154
- if (existsSync(join(root, "app"))) return { appDir: "app", srcPrefix: "" };
702
+ if (existsSync4(join4(root, "src", "app"))) return { appDir: "src/app", srcPrefix: "src/" };
703
+ if (existsSync4(join4(root, "app"))) return { appDir: "app", srcPrefix: "" };
155
704
  return null;
156
705
  }
157
706
  function hasDependency(root, name) {
158
- const packagePath = join(root, "package.json");
159
- if (!existsSync(packagePath)) return false;
707
+ const packagePath = join4(root, "package.json");
708
+ if (!existsSync4(packagePath)) return false;
160
709
  const pkg = readJson(packagePath);
161
710
  return Boolean(pkg.dependencies?.[name] ?? pkg.devDependencies?.[name]);
162
711
  }
163
712
  function detectTanstackStartApp(root) {
164
- return hasDependency(root, "@tanstack/react-start") && existsSync(join(root, "src", "routes"));
713
+ return hasDependency(root, "@tanstack/react-start") && existsSync4(join4(root, "src", "routes"));
165
714
  }
166
715
  var AGENTS_FILE = "AGENTS.md";
167
716
  var CURSOR_RULE_FILE = ".cursor/rules/oneteam-apps.mdc";
168
717
  var AGENTS_MARKER = "<!-- oneteam-apps:start -->";
169
718
  var AGENTS_END = "<!-- oneteam-apps:end -->";
170
- function writeAgentInstructions(root, out2, force) {
719
+ function writeAgentSection(root, instructions, out2) {
171
720
  const section = `${AGENTS_MARKER}
172
- ${AGENT_INSTRUCTIONS}
721
+ ${instructions}
173
722
  ${AGENTS_END}`;
174
- const agentsPath = join(root, AGENTS_FILE);
175
- const existing = existsSync(agentsPath) ? readFileSync(agentsPath, "utf8") : null;
723
+ const agentsPath = join4(root, AGENTS_FILE);
724
+ const existing = existsSync4(agentsPath) ? readFileSync4(agentsPath, "utf8") : null;
176
725
  const start = existing?.indexOf(AGENTS_MARKER) ?? -1;
177
726
  const end = existing && start >= 0 ? existing.indexOf(AGENTS_END, start) : -1;
178
727
  if (existing === null) {
@@ -189,6 +738,9 @@ ${section}
189
738
  `);
190
739
  out2.info(`added a Oneteam section to ${AGENTS_FILE}`);
191
740
  }
741
+ }
742
+ function writeAgentInstructions(root, out2, force) {
743
+ writeAgentSection(root, AGENT_INSTRUCTIONS, out2);
192
744
  writeIfMissing(root, CURSOR_RULE_FILE, CURSOR_RULE, out2, force);
193
745
  }
194
746
  function init(root, options, out2) {
@@ -205,16 +757,16 @@ function init(root, options, out2) {
205
757
  return 1;
206
758
  }
207
759
  writeAgentInstructions(root, out2, force);
208
- if (!existsSync(join(root, MANIFEST_FILE)) || force) {
209
- const name = options.name ?? readJson(join(root, "package.json")).name ?? basename(root);
760
+ if (!existsSync4(join4(root, MANIFEST_FILE)) || force) {
761
+ const name = options.name ?? readJson(join4(root, "package.json")).name ?? basename(root);
210
762
  writeIfMissing(root, MANIFEST_FILE, `${JSON.stringify(manifest(name, options.url), null, 2)}
211
763
  `, out2, force);
212
764
  if (!options.url) out2.warn(`set your production domain in ${MANIFEST_FILE} (or re-run with --url)`);
213
765
  } else {
214
766
  out2.warn(`skipped ${MANIFEST_FILE} (already exists)`);
215
767
  }
216
- const envPath = join(root, ".env.example");
217
- const envContents = existsSync(envPath) ? readFileSync(envPath, "utf8") : "";
768
+ const envPath = join4(root, ".env.example");
769
+ const envContents = existsSync4(envPath) ? readFileSync4(envPath, "utf8") : "";
218
770
  const missing = ENV_KEYS.filter(({ key }) => !new RegExp(`^${key}=`, "m").test(envContents));
219
771
  if (missing.length > 0) {
220
772
  const block = missing.map(({ key, comment, value }) => `# ${comment}
@@ -237,7 +789,7 @@ function initNext(root, next, out2, force) {
237
789
  writeIfMissing(root, libPath, libFile(next.srcPrefix ? "../../oneteam.app.json" : "../oneteam.app.json"), out2, force);
238
790
  writeIfMissing(root, `${next.appDir}/api/oneteam/[...oneteam]/route.ts`, routeFile(libImport), out2, force);
239
791
  const existingGate = ["proxy.ts", "middleware.ts", "src/proxy.ts", "src/middleware.ts"].find(
240
- (file) => existsSync(join(root, file))
792
+ (file) => existsSync4(join4(root, file))
241
793
  );
242
794
  if (existingGate && !force) {
243
795
  out2.warn(
@@ -251,7 +803,7 @@ function initTanstackStart(root, out2, force) {
251
803
  writeIfMissing(root, "src/lib/oneteam.server.ts", tanstackLibFile("../../oneteam.app.json"), out2, force);
252
804
  writeIfMissing(root, "src/lib/oneteam.functions.ts", tanstackFunctionsFile(), out2, force);
253
805
  writeIfMissing(root, "src/routes/api/oneteam/$.ts", tanstackRouteFile(), out2, force);
254
- if (existsSync(join(root, "src", "start.ts")) && !force) {
806
+ if (existsSync4(join4(root, "src", "start.ts")) && !force) {
255
807
  out2.warn(
256
808
  "found src/start.ts: not replacing it. Add a request middleware that calls oneteam.guard() so every request requires a session (see the TanStack Start example app)."
257
809
  );
@@ -260,9 +812,9 @@ function initTanstackStart(root, out2, force) {
260
812
  }
261
813
  }
262
814
  function validate(root, file, out2) {
263
- const path = join(root, file ?? MANIFEST_FILE);
264
- if (!existsSync(path)) {
265
- out2.error(`${relative(root, path) || path} not found. Run \`oneteam-apps init\` first.`);
815
+ const path = join4(root, file ?? MANIFEST_FILE);
816
+ if (!existsSync4(path)) {
817
+ out2.error(`${relative2(root, path) || path} not found. Run \`oneteam-apps init\` first.`);
266
818
  return { code: 1 };
267
819
  }
268
820
  let input;
@@ -277,12 +829,14 @@ function validate(root, file, out2) {
277
829
  for (const issue of result.errors) out2.error(`${issue.path}: ${issue.message}`);
278
830
  return { code: 1 };
279
831
  }
280
- out2.info(`${MANIFEST_FILE} is valid (scopes: ${result.manifest.scopes.join(" ")})`);
832
+ out2.info(
833
+ isHostedManifest(result.manifest) ? `${MANIFEST_FILE} is valid (hosted app "${result.manifest.slug}")` : `${MANIFEST_FILE} is valid (scopes: ${result.manifest.scopes.join(" ")})`
834
+ );
281
835
  return { code: 0, manifest: result.manifest };
282
836
  }
283
837
  function installedVersion(root, name) {
284
838
  try {
285
- return readJson(join(root, "node_modules", name, "package.json")).version ?? null;
839
+ return readJson(join4(root, "node_modules", name, "package.json")).version ?? null;
286
840
  } catch {
287
841
  return null;
288
842
  }
@@ -297,9 +851,9 @@ function isPatchedNext(version) {
297
851
  function loadEnv(root) {
298
852
  const env = {};
299
853
  for (const file of [".env", ".env.local"]) {
300
- const path = join(root, file);
301
- if (!existsSync(path)) continue;
302
- for (const line of readFileSync(path, "utf8").split("\n")) {
854
+ const path = join4(root, file);
855
+ if (!existsSync4(path)) continue;
856
+ for (const line of readFileSync4(path, "utf8").split("\n")) {
303
857
  const match = /^\s*([A-Z0-9_]+)\s*=\s*(.*)\s*$/.exec(line);
304
858
  if (match) env[match[1]] = match[2].replace(/^(['"])(.*)\1$/, "$2");
305
859
  }
@@ -313,8 +867,10 @@ async function doctor(root, options, out2) {
313
867
  out2.error(message);
314
868
  };
315
869
  const env = { ...loadEnv(root), ...options.env };
316
- const { manifest: parsed } = validate(root, void 0, out2);
317
- if (!parsed) failures += 1;
870
+ const { manifest: validated } = validate(root, void 0, out2);
871
+ if (!validated) failures += 1;
872
+ if (validated && isHostedManifest(validated)) return doctorHosted(root, validated, out2, failures);
873
+ const parsed = validated;
318
874
  for (const { key } of ENV_KEYS) {
319
875
  if (!env[key]) fail(`${key} is not set (.env.local)`);
320
876
  }
@@ -336,19 +892,19 @@ async function doctor(root, options, out2) {
336
892
  }
337
893
  }
338
894
  if (detectTanstackStartApp(root)) {
339
- const start = join(root, "src", "start.ts");
340
- if (!existsSync(start)) {
895
+ const start = join4(root, "src", "start.ts");
896
+ if (!existsSync4(start)) {
341
897
  fail("no src/start.ts: requests are not protected by default. Run `oneteam-apps init`.");
342
- } else if (!readFileSync(start, "utf8").includes("oneteam.guard")) {
898
+ } else if (!readFileSync4(start, "utf8").includes("oneteam.guard")) {
343
899
  fail("src/start.ts does not call oneteam.guard() from a request middleware: requests are not protected by default");
344
900
  }
345
901
  } else {
346
902
  const gate = ["proxy.ts", "middleware.ts", "src/proxy.ts", "src/middleware.ts"].find(
347
- (file) => existsSync(join(root, file))
903
+ (file) => existsSync4(join4(root, file))
348
904
  );
349
905
  if (!gate) {
350
906
  fail("no proxy.ts: routes are not protected by default. Run `oneteam-apps init`.");
351
- } else if (!readFileSync(join(root, gate), "utf8").includes("oneteam.proxy")) {
907
+ } else if (!readFileSync4(join4(root, gate), "utf8").includes("oneteam.proxy")) {
352
908
  out2.warn(`${gate} does not call oneteam.proxy(); make sure it protects every route`);
353
909
  }
354
910
  }
@@ -372,47 +928,1380 @@ async function doctor(root, options, out2) {
372
928
  out2.info(failures === 0 ? "All checks passed" : `${failures} problem(s) found`);
373
929
  return failures === 0 ? 0 : 1;
374
930
  }
931
+ var MAX_UI_WARNINGS = 25;
932
+ function doctorHosted(root, manifest2, out2, failuresSoFar) {
933
+ let failures = failuresSoFar;
934
+ const fail = (message) => {
935
+ failures += 1;
936
+ out2.error(message);
937
+ };
938
+ const worker = join4(root, "src", "worker", "index.ts");
939
+ if (!existsSync4(worker)) {
940
+ fail("src/worker/index.ts not found: a hosted app's API lives there");
941
+ } else {
942
+ const source = readFileSync4(worker, "utf8");
943
+ if (!source.includes("@oneteam-app/apps/hosted")) fail("src/worker/index.ts does not use @oneteam-app/apps/hosted: requests are not tied to a signed-in user");
944
+ if (source.includes("@oneteam-app/apps/testing")) fail("src/worker/index.ts imports @oneteam-app/apps/testing: the stand-in sign-on must stay in src/worker/dev.ts");
945
+ }
946
+ if (!existsSync4(join4(root, "migrations"))) out2.warn("no migrations/ folder: the app has no database tables");
947
+ const jobs = checkJobsAndSends(root, manifest2);
948
+ for (const problem of jobs.errors) fail(problem);
949
+ for (const warning of jobs.warnings) out2.warn(warning);
950
+ const problems = findSafetyProblems(root);
951
+ if (hasSafetyProblems(problems)) {
952
+ failures += 1;
953
+ for (const line of describeSafetyProblems(problems)) out2.error(line);
954
+ }
955
+ const ignore = existsSync4(join4(root, ".gitignore")) ? readFileSync4(join4(root, ".gitignore"), "utf8").split(/\r?\n/).map((line) => line.trim()) : [];
956
+ if (!ignore.some((line) => /^\/?\.dev\.vars\*?$/.test(line))) {
957
+ fail(".gitignore does not list .dev.vars: local secret values would be committed. Add `.dev.vars*` and `.env*`.");
958
+ }
959
+ const declared = manifest2.secrets.map((secret) => secret.name);
960
+ const local = [".dev.vars", ".env", ".env.local"].flatMap(
961
+ (file) => existsSync4(join4(root, file)) ? parseEnvFile(readFileSync4(join4(root, file), "utf8")).map((entry) => ({ ...entry, file })) : []
962
+ );
963
+ for (const { name, file } of local) {
964
+ if (/^VITE_/.test(name) && (declared.includes(name.replace(/^VITE_/, "")) || /SERVICE|SECRET|PRIVATE|PASSWORD/.test(name))) {
965
+ fail(`${name} in ${file} would be built into the front end, where anyone can read it. Remove the VITE_ prefix and read it in the Worker.`);
966
+ }
967
+ }
968
+ const withoutLocalValue = declared.filter((name) => !local.some((entry) => entry.name === name && entry.value));
969
+ if (withoutLocalValue.length > 0) {
970
+ out2.warn(`no local value for ${withoutLocalValue.join(", ")}: add ${withoutLocalValue.length === 1 ? "it" : "them"} to .dev.vars yourself (never commit that file) to run the app locally`);
971
+ }
972
+ const undeclared = local.filter((entry) => entry.file === ".dev.vars" && SECRET_NAME.test(entry.name) && !declared.includes(entry.name)).map((entry) => entry.name);
973
+ if (undeclared.length > 0) {
974
+ out2.warn(`.dev.vars has ${undeclared.join(", ")}, which ${MANIFEST_FILE} does not list under "secrets": the deployed app will not have ${undeclared.length === 1 ? "it" : "them"}`);
975
+ }
976
+ if (existsSync4(join4(root, "src", "client"))) {
977
+ if (!usesUiPackage(root)) {
978
+ out2.warn("@oneteam-app/ui is not installed: the app will not look like the rest, and every button and form is yours to get right. Add it and wrap the app in <OneteamTheme>.");
979
+ }
980
+ const design = findUiWarnings(root);
981
+ for (const warning of design.slice(0, MAX_UI_WARNINGS)) out2.warn(`${warning.file}:${warning.line} ${warning.message}`);
982
+ if (design.length > MAX_UI_WARNINGS) out2.warn(`\u2026 and ${design.length - MAX_UI_WARNINGS} more of the same kind`);
983
+ }
984
+ out2.info(failures === 0 ? "All checks passed" : `${failures} problem(s) found`);
985
+ return failures === 0 ? 0 : 1;
986
+ }
987
+
988
+ // src/hosted/auth.ts
989
+ import { spawn } from "child_process";
990
+ import { createHash, randomBytes } from "crypto";
991
+ import { chmodSync, existsSync as existsSync5, mkdirSync as mkdirSync2, readFileSync as readFileSync5, writeFileSync as writeFileSync2 } from "fs";
992
+ import { createServer } from "http";
993
+ import { homedir } from "os";
994
+ import { dirname as dirname2, join as join5 } from "path";
995
+
996
+ // src/hosted/api.ts
997
+ var DEFAULT_API_URL = "https://api.oneteam.io";
998
+ var ApiError = class extends Error {
999
+ constructor(status2, errorCode, message) {
1000
+ super(message);
1001
+ this.status = status2;
1002
+ this.errorCode = errorCode;
1003
+ this.name = "ApiError";
1004
+ }
1005
+ status;
1006
+ errorCode;
1007
+ };
1008
+ function assertSafeApiUrl(value) {
1009
+ let url;
1010
+ try {
1011
+ url = new URL(value);
1012
+ } catch {
1013
+ throw new Error(`ONETEAM_API_URL is not a valid URL: ${value}`);
1014
+ }
1015
+ const loopback = ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
1016
+ if (url.protocol !== "https:" && !(url.protocol === "http:" && loopback)) {
1017
+ throw new Error(`ONETEAM_API_URL must use https (http is only allowed on localhost): ${value}`);
1018
+ }
1019
+ return url.toString().replace(/\/+$/, "");
1020
+ }
1021
+ function createApiClient(options) {
1022
+ const apiUrl = assertSafeApiUrl(options.apiUrl);
1023
+ const fetchImpl = options.fetch ?? fetch;
1024
+ async function request(method, path, body, contentType) {
1025
+ let response;
1026
+ try {
1027
+ response = await fetchImpl(`${apiUrl}${path}`, {
1028
+ method,
1029
+ headers: {
1030
+ authorization: `Bearer ${options.token}`,
1031
+ accept: "application/json",
1032
+ ...contentType && { "content-type": contentType }
1033
+ },
1034
+ body
1035
+ });
1036
+ } catch (error) {
1037
+ throw new Error(`Could not reach ${apiUrl}: ${error.message}`);
1038
+ }
1039
+ const text = await response.text();
1040
+ let parsed = null;
1041
+ try {
1042
+ parsed = text ? JSON.parse(text) : null;
1043
+ } catch {
1044
+ }
1045
+ if (!response.ok) {
1046
+ const error = parsed ?? {};
1047
+ if (response.status === 401) {
1048
+ throw new ApiError(401, error.error_code, "Not signed in, or the token has expired. Run `oneteam-apps login`, or set ONETEAM_TOKEN.");
1049
+ }
1050
+ throw new ApiError(response.status, error.error_code, error.detail ?? error.message ?? `${method} ${path} failed with ${response.status}`);
1051
+ }
1052
+ return parsed && typeof parsed === "object" && "data" in parsed ? parsed.data : parsed;
1053
+ }
1054
+ return {
1055
+ apiUrl,
1056
+ get: (path) => request("GET", path),
1057
+ post: (path, body) => request("POST", path, JSON.stringify(body), "application/json"),
1058
+ put: (path, body) => request("PUT", path, JSON.stringify(body), "application/json"),
1059
+ delete: (path) => request("DELETE", path),
1060
+ // fetch sets the multipart boundary itself.
1061
+ upload: (path, form) => request("POST", path, form)
1062
+ };
1063
+ }
1064
+
1065
+ // src/hosted/auth.ts
1066
+ function apiUrlFrom(env, flag) {
1067
+ return assertSafeApiUrl(flag ?? env.ONETEAM_API_URL ?? DEFAULT_API_URL);
1068
+ }
1069
+ function credentialsPath(env) {
1070
+ return join5(env.XDG_CONFIG_HOME || join5(homedir(), ".config"), "oneteam-apps", "credentials.json");
1071
+ }
1072
+ function readAll(env) {
1073
+ try {
1074
+ return JSON.parse(readFileSync5(credentialsPath(env), "utf8"));
1075
+ } catch {
1076
+ return {};
1077
+ }
1078
+ }
1079
+ function readCredentials(env, apiUrl) {
1080
+ return readAll(env)[apiUrl] ?? null;
1081
+ }
1082
+ function writeCredentials(env, apiUrl, credentials) {
1083
+ const path = credentialsPath(env);
1084
+ mkdirSync2(dirname2(path), { recursive: true, mode: 448 });
1085
+ writeFileSync2(path, `${JSON.stringify({ ...readAll(env), [apiUrl]: credentials }, null, 2)}
1086
+ `, { mode: 384 });
1087
+ chmodSync(path, 384);
1088
+ }
1089
+ async function refresh(credentials, fetchImpl) {
1090
+ if (!credentials.refresh_token) return null;
1091
+ const response = await fetchImpl(credentials.token_endpoint, {
1092
+ method: "POST",
1093
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1094
+ body: new URLSearchParams({ grant_type: "refresh_token", refresh_token: credentials.refresh_token, client_id: credentials.client_id })
1095
+ }).catch(() => null);
1096
+ if (!response?.ok) return null;
1097
+ const tokens = await response.json();
1098
+ if (!tokens.access_token) return null;
1099
+ return {
1100
+ ...credentials,
1101
+ access_token: tokens.access_token,
1102
+ refresh_token: tokens.refresh_token ?? credentials.refresh_token,
1103
+ expires_at: Math.floor(Date.now() / 1e3) + (tokens.expires_in ?? 3600)
1104
+ };
1105
+ }
1106
+ async function resolveToken(env, apiUrl, fetchImpl = fetch) {
1107
+ if (env.ONETEAM_TOKEN) return env.ONETEAM_TOKEN;
1108
+ const stored = readCredentials(env, apiUrl);
1109
+ if (!stored) return null;
1110
+ if (!stored.expires_at || stored.expires_at - 60 > Date.now() / 1e3) return stored.access_token;
1111
+ const refreshed = await refresh(stored, fetchImpl);
1112
+ if (!refreshed) return null;
1113
+ writeCredentials(env, apiUrl, refreshed);
1114
+ return refreshed.access_token;
1115
+ }
1116
+ function isHttpsOrLoopback(value) {
1117
+ try {
1118
+ const url = new URL(value);
1119
+ return url.protocol === "https:" || url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
1120
+ } catch {
1121
+ return false;
1122
+ }
1123
+ }
1124
+ async function getJson(url, fetchImpl) {
1125
+ const response = await fetchImpl(url, { headers: { accept: "application/json" } }).catch(() => null);
1126
+ if (!response?.ok) return null;
1127
+ return await response.json().catch(() => null);
1128
+ }
1129
+ async function discover(apiUrl, fetchImpl) {
1130
+ const resource = await getJson(
1131
+ `${apiUrl}/.well-known/oauth-protected-resource`,
1132
+ fetchImpl
1133
+ );
1134
+ const issuer = resource?.authorization_servers?.[0]?.replace(/\/+$/, "");
1135
+ if (!issuer || !isHttpsOrLoopback(issuer)) {
1136
+ throw new Error(`${apiUrl} does not say where to sign in (no OAuth protected-resource metadata). Set ONETEAM_TOKEN instead.`);
1137
+ }
1138
+ const metadata = await getJson(`${issuer}/.well-known/oauth-authorization-server`, fetchImpl) ?? await getJson(`${issuer}/.well-known/openid-configuration`, fetchImpl);
1139
+ if (!metadata || metadata.issuer?.replace(/\/+$/, "") !== issuer) {
1140
+ throw new Error(`Could not load the sign-in configuration of ${issuer}. Set ONETEAM_TOKEN instead.`);
1141
+ }
1142
+ const { authorization_endpoint: authorizationEndpoint, token_endpoint: tokenEndpoint, registration_endpoint: registrationEndpoint } = metadata;
1143
+ if (!authorizationEndpoint || !tokenEndpoint || !registrationEndpoint) {
1144
+ throw new Error(`${issuer} does not support signing in from a command line tool (no client registration). Set ONETEAM_TOKEN instead.`);
1145
+ }
1146
+ if (!metadata.code_challenge_methods_supported?.includes("S256")) {
1147
+ throw new Error(`${issuer} does not support PKCE (S256). Set ONETEAM_TOKEN instead.`);
1148
+ }
1149
+ for (const endpoint of [authorizationEndpoint, tokenEndpoint, registrationEndpoint]) {
1150
+ if (!isHttpsOrLoopback(endpoint)) throw new Error(`${issuer} lists an endpoint that is not https: ${endpoint}`);
1151
+ }
1152
+ return { issuer, authorizationEndpoint, tokenEndpoint, registrationEndpoint, resource: resource?.resource };
1153
+ }
1154
+ function base64Url(bytes) {
1155
+ return Buffer.from(bytes).toString("base64url");
1156
+ }
1157
+ function openBrowser(url) {
1158
+ const [command, args] = process.platform === "darwin" ? ["open", [url]] : process.platform === "win32" ? ["cmd", ["/c", "start", '""', url]] : ["xdg-open", [url]];
1159
+ try {
1160
+ spawn(command, args, { stdio: "ignore", detached: true }).on("error", () => {
1161
+ }).unref();
1162
+ } catch {
1163
+ }
1164
+ }
1165
+ var DONE_PAGE = `<!doctype html><meta charset="utf-8"><title>Signed in</title>
1166
+ <body style="font-family:system-ui;display:grid;place-items:center;min-height:100vh;margin:0"><p>You're signed in. You can close this tab and go back to the terminal.</p></body>`;
1167
+ async function login(options, out2) {
1168
+ const fetchImpl = options.fetch ?? fetch;
1169
+ const { issuer, authorizationEndpoint, tokenEndpoint, registrationEndpoint, resource } = await discover(options.apiUrl, fetchImpl);
1170
+ const state = base64Url(randomBytes(24));
1171
+ const verifier = base64Url(randomBytes(48));
1172
+ const challenge = base64Url(createHash("sha256").update(verifier).digest());
1173
+ const server = createServer();
1174
+ await new Promise((resolve2, reject) => server.once("error", reject).listen(0, "127.0.0.1", resolve2));
1175
+ const { port } = server.address();
1176
+ const redirectUri = `http://127.0.0.1:${port}/callback`;
1177
+ try {
1178
+ const registration = await fetchImpl(registrationEndpoint, {
1179
+ method: "POST",
1180
+ headers: { "content-type": "application/json", accept: "application/json" },
1181
+ body: JSON.stringify({
1182
+ client_name: "Oneteam Apps CLI",
1183
+ redirect_uris: [redirectUri],
1184
+ grant_types: ["authorization_code", "refresh_token"],
1185
+ response_types: ["code"],
1186
+ token_endpoint_auth_method: "none"
1187
+ })
1188
+ });
1189
+ const client = await registration.json().catch(() => ({}));
1190
+ if (!registration.ok || !client.client_id) throw new Error(`${issuer} refused to register the command line tool (${registration.status}). Set ONETEAM_TOKEN instead.`);
1191
+ const authorize = new URL(authorizationEndpoint);
1192
+ authorize.searchParams.set("response_type", "code");
1193
+ authorize.searchParams.set("client_id", client.client_id);
1194
+ authorize.searchParams.set("redirect_uri", redirectUri);
1195
+ authorize.searchParams.set("scope", "openid profile email offline_access");
1196
+ authorize.searchParams.set("state", state);
1197
+ authorize.searchParams.set("code_challenge", challenge);
1198
+ authorize.searchParams.set("code_challenge_method", "S256");
1199
+ if (resource) authorize.searchParams.set("resource", resource);
1200
+ const code = new Promise((resolve2, reject) => {
1201
+ const timer = setTimeout(() => reject(new Error("Timed out waiting for the sign-in to finish")), options.timeoutMs ?? 5 * 6e4);
1202
+ server.on("request", (request, response) => {
1203
+ const url = new URL(request.url ?? "/", redirectUri);
1204
+ if (url.pathname !== "/callback") {
1205
+ response.writeHead(404).end();
1206
+ return;
1207
+ }
1208
+ const failed = url.searchParams.get("error");
1209
+ const ours = url.searchParams.get("state") === state;
1210
+ response.writeHead(ours && !failed ? 200 : 400, { "content-type": "text/html; charset=utf-8" });
1211
+ response.end(ours && !failed ? DONE_PAGE : "Sign-in failed. Go back to the terminal.");
1212
+ if (!ours) return;
1213
+ clearTimeout(timer);
1214
+ if (failed || !url.searchParams.get("code")) reject(new Error(`Sign-in was refused: ${url.searchParams.get("error_description") ?? failed ?? "no code"}`));
1215
+ else resolve2(url.searchParams.get("code"));
1216
+ });
1217
+ });
1218
+ out2.info("Sign in with your browser:");
1219
+ out2.info(` ${authorize.toString()}`);
1220
+ if (options.open) options.open(authorize.toString());
1221
+ else if (!options.noBrowser) openBrowser(authorize.toString());
1222
+ const tokenResponse = await fetchImpl(tokenEndpoint, {
1223
+ method: "POST",
1224
+ headers: { "content-type": "application/x-www-form-urlencoded", accept: "application/json" },
1225
+ body: new URLSearchParams({
1226
+ grant_type: "authorization_code",
1227
+ code: await code,
1228
+ redirect_uri: redirectUri,
1229
+ client_id: client.client_id,
1230
+ code_verifier: verifier,
1231
+ ...resource && { resource }
1232
+ })
1233
+ });
1234
+ const tokens = await tokenResponse.json().catch(() => ({}));
1235
+ if (!tokenResponse.ok || !tokens.access_token) throw new Error(`Could not finish signing in: ${tokens.error ?? tokenResponse.status}`);
1236
+ writeCredentials(options.env, options.apiUrl, {
1237
+ access_token: tokens.access_token,
1238
+ ...tokens.refresh_token && { refresh_token: tokens.refresh_token },
1239
+ expires_at: Math.floor(Date.now() / 1e3) + (tokens.expires_in ?? 3600),
1240
+ client_id: client.client_id,
1241
+ token_endpoint: tokenEndpoint,
1242
+ ...options.organisationId && { organisation_id: options.organisationId }
1243
+ });
1244
+ out2.info(`Signed in. Credentials saved to ${credentialsPath(options.env)}`);
1245
+ return 0;
1246
+ } finally {
1247
+ server.close();
1248
+ server.closeAllConnections?.();
1249
+ }
1250
+ }
1251
+ function describeAuth(env, apiUrl) {
1252
+ if (env.ONETEAM_TOKEN) return "using ONETEAM_TOKEN";
1253
+ if (existsSync5(credentialsPath(env)) && readCredentials(env, apiUrl)) return `signed in (${credentialsPath(env)})`;
1254
+ return "not signed in";
1255
+ }
1256
+
1257
+ // src/hosted/deploy.ts
1258
+ import { writeFileSync as writeFileSync3 } from "fs";
1259
+ import { join as join7 } from "path";
1260
+ import {
1261
+ HOSTED_APP_SCOPES,
1262
+ parseAppVersion,
1263
+ parseHostedApp
1264
+ } from "@oneteam-app/apps/contracts";
1265
+
1266
+ // src/hosted/project.ts
1267
+ import { existsSync as existsSync6, readFileSync as readFileSync6 } from "fs";
1268
+ import { join as join6 } from "path";
1269
+ import { isHostedManifest as isHostedManifest2, validateManifest as validateManifest2 } from "@oneteam-app/apps/contracts";
1270
+ function readProject(root, out2) {
1271
+ const path = join6(root, MANIFEST_FILE);
1272
+ if (!existsSync6(path)) {
1273
+ out2.error(`${MANIFEST_FILE} not found. Run this in the app's folder, or start one with \`oneteam-apps init --hosted <name>\`.`);
1274
+ return null;
1275
+ }
1276
+ let raw;
1277
+ try {
1278
+ raw = JSON.parse(readFileSync6(path, "utf8"));
1279
+ } catch (error) {
1280
+ out2.error(`${MANIFEST_FILE} is not valid JSON: ${error.message}`);
1281
+ return null;
1282
+ }
1283
+ const result = validateManifest2(raw);
1284
+ if (!result.success) {
1285
+ for (const issue of result.errors) out2.error(`${MANIFEST_FILE} ${issue.path}: ${issue.message}`);
1286
+ return null;
1287
+ }
1288
+ if (!isHostedManifest2(result.manifest)) {
1289
+ out2.error(`${MANIFEST_FILE} describes an app you host yourself. Only hosted apps ("hosting": "hosted") are deployed with this command.`);
1290
+ return null;
1291
+ }
1292
+ return { raw, manifest: result.manifest };
1293
+ }
1294
+ async function connect(options, out2) {
1295
+ const apiUrl = apiUrlFrom(options.env, options.api);
1296
+ const token = await resolveToken(options.env, apiUrl, options.fetch);
1297
+ if (!token) {
1298
+ out2.error("Not signed in. Run `oneteam-apps login`, or set ONETEAM_TOKEN (a personal access token from your profile).");
1299
+ return null;
1300
+ }
1301
+ return createApiClient({ apiUrl, token, fetch: options.fetch });
1302
+ }
1303
+ function organisationFor(options, manifest2, apiUrl) {
1304
+ return options.organisation ?? manifest2.organisation_id ?? options.env.ONETEAM_ORGANISATION_ID ?? readCredentials(options.env, apiUrl)?.organisation_id ?? null;
1305
+ }
1306
+ var NO_ORGANISATION = "Which organisation is this app for? Pass --organisation <id> (or set ONETEAM_ORGANISATION_ID). The id is in the address of the admin dashboard, and the Oneteam connector lists it with list_my_organisations.";
1307
+ function appPathFor(organisationId, appId) {
1308
+ return `/v1/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(appId)}`;
1309
+ }
1310
+
1311
+ // src/hosted/jobs.ts
1312
+ import { parseArgs } from "util";
1313
+ import {
1314
+ JOB_NAME,
1315
+ parseAppSchedules,
1316
+ parseJobRun,
1317
+ SCHEDULE_INTERVALS
1318
+ } from "@oneteam-app/apps/contracts";
1319
+ var ENVS = ["live", "preview"];
1320
+ function describeSchedule(schedule) {
1321
+ const at = schedule.at ? ` at ${schedule.at}` : "";
1322
+ switch (schedule.every) {
1323
+ case "15_minutes":
1324
+ return "every 15 minutes";
1325
+ case "30_minutes":
1326
+ return "every 30 minutes";
1327
+ case "hour":
1328
+ return schedule.minute ? `every hour at ${schedule.minute} past` : "every hour";
1329
+ case "day":
1330
+ return `every day${at}`;
1331
+ case "week":
1332
+ return `every week on ${(schedule.weekdays ?? []).join(" and ")}${at}`;
1333
+ case "month":
1334
+ return `every month on day ${schedule.day_of_month ?? "?"}${at}`;
1335
+ default:
1336
+ return String(schedule.every);
1337
+ }
1338
+ }
1339
+ function when(iso) {
1340
+ return iso ? `${iso.replace("T", " ").slice(0, 16)} UTC` : "";
1341
+ }
1342
+ function describeRun(run) {
1343
+ const took = run.duration_ms === null ? "" : ` in ${run.duration_ms} ms`;
1344
+ const by = run.manual ? ", started by hand" : "";
1345
+ if (run.status === "succeeded") return `succeeded ${when(run.finished_at ?? run.started_at ?? run.scheduled_for)}${took}${by}`;
1346
+ if (run.status === "failed") return `FAILED ${when(run.finished_at ?? run.started_at ?? run.scheduled_for)}${by}${run.error ? `: ${run.error}` : ""}`;
1347
+ if (run.status === "missed") return `missed ${when(run.scheduled_for)} (not caught up)`;
1348
+ return run.started_at ? `running since ${when(run.started_at)}${by}` : `waiting to start${by}`;
1349
+ }
1350
+ async function fetchSchedules(api, appPath, env) {
1351
+ try {
1352
+ return parseAppSchedules(await api.get(`${appPath}/schedules?env=${env}`));
1353
+ } catch {
1354
+ return null;
1355
+ }
1356
+ }
1357
+ function describeSchedules(env, schedules) {
1358
+ if (schedules.schedules.length === 0) return [];
1359
+ const width = Math.max(...schedules.schedules.map((job) => job.name.length));
1360
+ const lines = [`Scheduled jobs, ${env}${schedules.timezone ? ` (times in ${schedules.timezone})` : ""}`];
1361
+ for (const job of schedules.schedules) {
1362
+ lines.push(` ${job.name.padEnd(width)} ${describeSchedule(job)}`);
1363
+ lines.push(
1364
+ ` ${"".padEnd(width)} ${env === "preview" ? "does not run by itself in preview" : job.next_run_at ? `next run ${when(job.next_run_at)}` : "not scheduled (the app is paused or not live)"}${job.last_run ? `; last run ${describeRun(job.last_run)}` : "; has not run yet"}`
1365
+ );
1366
+ }
1367
+ for (const sent of schedules.sends) lines.push(` Sent in the last 14 days: ${sent.sent} ${sent.kind.replace("_", " ")}`);
1368
+ return lines;
1369
+ }
1370
+ function needsApproval(manifest2, live) {
1371
+ const reasons = [];
1372
+ const rank = (every) => SCHEDULE_INTERVALS.indexOf(every);
1373
+ for (const schedule of manifest2.schedules) {
1374
+ const current = live?.schedules.find((job) => job.name === schedule.name);
1375
+ if (!current) reasons.push(`the new job "${schedule.name}" (${describeSchedule(schedule)})`);
1376
+ else if (rank(schedule.every) < rank(current.every)) reasons.push(`"${schedule.name}" running more often (${describeSchedule(schedule)}, was ${describeSchedule(current)})`);
1377
+ else if (schedule.every === "week" && (schedule.weekdays ?? []).some((day) => !current.weekdays.includes(day))) reasons.push(`"${schedule.name}" running on more days (${describeSchedule(schedule)})`);
1378
+ }
1379
+ for (const kind of manifest2.sends) {
1380
+ if (!live?.sends.some((sent) => sent.kind === kind)) reasons.push(`sending ${kind.replace("_", " ")}`);
1381
+ }
1382
+ return reasons;
1383
+ }
1384
+ async function target(root, options, out2) {
1385
+ const project = readProject(root, out2);
1386
+ if (!project) return null;
1387
+ if (!project.manifest.id) {
1388
+ out2.error("This app has not been deployed yet. Run `oneteam-apps deploy` first.");
1389
+ return null;
1390
+ }
1391
+ const api = await connect(options, out2);
1392
+ if (!api) return null;
1393
+ const organisationId = organisationFor(options, project.manifest, api.apiUrl);
1394
+ if (!organisationId) {
1395
+ out2.error(NO_ORGANISATION);
1396
+ return null;
1397
+ }
1398
+ return { manifest: project.manifest, api, appPath: appPathFor(organisationId, project.manifest.id) };
1399
+ }
1400
+ async function jobList(root, options, out2) {
1401
+ const print = options.print ?? ((text) => console.log(text));
1402
+ if (options.appEnv !== void 0 && options.appEnv !== "live" && options.appEnv !== "preview") {
1403
+ out2.error('--env must be "live" or "preview"');
1404
+ return 1;
1405
+ }
1406
+ const found = await target(root, options, out2);
1407
+ if (!found) return 1;
1408
+ const envs = options.appEnv ? [options.appEnv] : ENVS;
1409
+ const results = await Promise.all(envs.map((env) => fetchSchedules(found.api, found.appPath, env)));
1410
+ if (results.some((result) => !result)) {
1411
+ out2.error("The API did not return the schedules.");
1412
+ return 1;
1413
+ }
1414
+ if (options.json) {
1415
+ print(JSON.stringify(Object.fromEntries(envs.map((env, index) => [env, results[index]])), null, 2));
1416
+ return 0;
1417
+ }
1418
+ let any = false;
1419
+ envs.forEach((env, index) => {
1420
+ const lines = describeSchedules(env, results[index]);
1421
+ if (lines.length === 0) return;
1422
+ if (any) out2.info("");
1423
+ any = true;
1424
+ for (const line of lines) out2.info(line);
1425
+ });
1426
+ if (!any) {
1427
+ const local = found.manifest.schedules.map((schedule) => schedule.name);
1428
+ out2.info(
1429
+ local.length > 0 ? `No scheduled jobs deployed yet. ${MANIFEST_FILE} lists ${local.join(", ")}: run \`oneteam-apps deploy\`.` : `This app has no scheduled jobs. Add them under "schedules" in ${MANIFEST_FILE}.`
1430
+ );
1431
+ }
1432
+ return 0;
1433
+ }
1434
+ async function jobRun(root, name, options, out2) {
1435
+ const print = options.print ?? ((text) => console.log(text));
1436
+ if (!name) {
1437
+ out2.error("Which job? oneteam-apps job run NAME --env live|preview");
1438
+ return 1;
1439
+ }
1440
+ if (!JOB_NAME.test(name)) {
1441
+ out2.error("A job's name is lowercase letters, digits and hyphens, e.g. send-reminders.");
1442
+ return 1;
1443
+ }
1444
+ if (options.appEnv !== "live" && options.appEnv !== "preview") {
1445
+ out2.error(
1446
+ options.appEnv === void 0 ? "Say where: --env preview runs the version waiting for approval, --env live the one colleagues use. A job run by hand does what it does on its schedule, sending included." : '--env must be "live" or "preview"'
1447
+ );
1448
+ return 1;
1449
+ }
1450
+ const found = await target(root, options, out2);
1451
+ if (!found) return 1;
1452
+ try {
1453
+ const run = parseJobRun(await found.api.post(`${found.appPath}/schedules/${encodeURIComponent(name)}/run?env=${options.appEnv}`, {}));
1454
+ if (options.json) {
1455
+ print(JSON.stringify({ requested: true, name, env: options.appEnv, run }, null, 2));
1456
+ return 0;
1457
+ }
1458
+ out2.info(`Asked for a run of ${name} in ${options.appEnv}. It starts within a minute; \`oneteam-apps job list --env ${options.appEnv}\` shows how it went.`);
1459
+ return 0;
1460
+ } catch (error) {
1461
+ out2.error(`Could not run ${name}: ${error.message}`);
1462
+ return 1;
1463
+ }
1464
+ }
1465
+ var USAGE = "Usage: oneteam-apps job list [--env live|preview] | oneteam-apps job run NAME --env live|preview";
1466
+ async function jobCommand(root, args, base, out2, quiet2 = out2) {
1467
+ const [action, ...rest] = args;
1468
+ let parsed;
1469
+ try {
1470
+ parsed = parseArgs({
1471
+ args: rest,
1472
+ allowPositionals: true,
1473
+ options: { api: { type: "string" }, organisation: { type: "string" }, json: { type: "boolean" }, env: { type: "string" } }
1474
+ });
1475
+ } catch (error) {
1476
+ out2.error(`${error.message}. ${USAGE}`);
1477
+ return 1;
1478
+ }
1479
+ const { values, positionals } = parsed;
1480
+ const options = { ...base, api: values.api, organisation: values.organisation, json: values.json, appEnv: values.env };
1481
+ const output = values.json ? quiet2 : out2;
1482
+ if (action === void 0 || action === "list") return positionals.length > 0 ? (out2.error(USAGE), 1) : jobList(root, options, output);
1483
+ if (action === "run") return positionals.length > 1 ? (out2.error("One job at a time: oneteam-apps job run NAME --env live|preview"), 1) : jobRun(root, positionals[0], options, output);
1484
+ out2.error(USAGE);
1485
+ return 1;
1486
+ }
1487
+
1488
+ // src/hosted/secrets.ts
1489
+ import { parseArgs as parseArgs2 } from "util";
1490
+ import { isReservedSecretName, parseAppSecrets, SECRET_NAME as SECRET_NAME2 } from "@oneteam-app/apps/contracts";
1491
+ var ENVS2 = ["preview", "live"];
1492
+ function readSecretValue(prompt, input) {
1493
+ const { stdin, stderr } = input;
1494
+ stdin.setEncoding("utf8");
1495
+ if (!stdin.isTTY || !stdin.setRawMode) {
1496
+ return new Promise((resolve2, reject) => {
1497
+ let value = "";
1498
+ stdin.on("data", ((chunk) => void (value += chunk)));
1499
+ stdin.on("end", (() => resolve2(value.replace(/\r?\n$/, ""))));
1500
+ stdin.on("error", ((error) => reject(error)));
1501
+ stdin.resume();
1502
+ });
1503
+ }
1504
+ return new Promise((resolve2, reject) => {
1505
+ let value = "";
1506
+ const finish = (error) => {
1507
+ stdin.removeListener("data", onData);
1508
+ stdin.setRawMode(false);
1509
+ stdin.pause();
1510
+ stderr.write("\n");
1511
+ if (error) reject(error);
1512
+ else resolve2(value);
1513
+ };
1514
+ const onData = (chunk) => {
1515
+ for (const char of chunk) {
1516
+ if (char === "\r" || char === "\n" || char === "") return finish();
1517
+ if (char === "") return finish(new Error("Cancelled. Nothing was saved."));
1518
+ if (char === "\x7F" || char === "\b") value = value.slice(0, -1);
1519
+ else if (char >= " ") value += char;
1520
+ }
1521
+ };
1522
+ stderr.write(prompt);
1523
+ stdin.setRawMode(true);
1524
+ stdin.on("data", onData);
1525
+ stdin.resume();
1526
+ });
1527
+ }
1528
+ async function target2(root, options, out2) {
1529
+ const project = readProject(root, out2);
1530
+ if (!project) return null;
1531
+ if (!project.manifest.id) {
1532
+ out2.error("This app has not been deployed yet. Run `oneteam-apps deploy` first: secrets belong to a registered app.");
1533
+ return null;
1534
+ }
1535
+ const api = await connect(options, out2);
1536
+ if (!api) return null;
1537
+ const organisationId = organisationFor(options, project.manifest, api.apiUrl);
1538
+ if (!organisationId) {
1539
+ out2.error(NO_ORGANISATION);
1540
+ return null;
1541
+ }
1542
+ return { project, api, appPath: appPathFor(organisationId, project.manifest.id) };
1543
+ }
1544
+ function envOption(value) {
1545
+ if (value === void 0) return void 0;
1546
+ return value === "live" || value === "preview" ? value : null;
1547
+ }
1548
+ function nameProblem(name, command) {
1549
+ if (!name) return `Which secret? oneteam-apps secret ${command} NAME --env live|preview`;
1550
+ if (!SECRET_NAME2.test(name)) return "A secret's name is capitals, digits and underscores, starting with a letter, e.g. SUPABASE_SERVICE_KEY.";
1551
+ if (isReservedSecretName(name)) return `${name} is reserved for the platform. Pick another name.`;
1552
+ return null;
1553
+ }
1554
+ var ENV_REQUIRED = "Say which environment: --env preview or --env live. Each has its own value and they are entered separately, so that a test key never ends up live by accident.";
1555
+ async function fetchSecrets(api, appPath, env) {
1556
+ return parseAppSecrets(await api.get(`${appPath}/secrets?env=${env}`));
1557
+ }
1558
+ async function missingSecrets(api, appPath) {
1559
+ try {
1560
+ const [preview, live] = await Promise.all(ENVS2.map((env) => fetchSecrets(api, appPath, env)));
1561
+ if (!preview || !live) return null;
1562
+ const names = (secrets) => secrets.secrets.filter((secret) => secret.status === "missing").map((secret) => secret.name);
1563
+ return { preview: names(preview), live: names(live) };
1564
+ } catch {
1565
+ return null;
1566
+ }
1567
+ }
1568
+ function describeMissingSecrets(missing) {
1569
+ if (missing.preview.length + missing.live.length === 0) return [];
1570
+ const lines = ["", "Secrets that still need a value"];
1571
+ for (const env of ENVS2) {
1572
+ if (missing[env].length > 0) lines.push(` ${`${env}:`.padEnd(9)}${missing[env].join(", ")}`);
1573
+ }
1574
+ const first = missing.preview[0] ?? missing.live[0];
1575
+ const firstEnv = missing.preview.length > 0 ? "preview" : "live";
1576
+ lines.push(
1577
+ " A person enters each value once, per environment: in the dashboard (the app's Secrets tab), or in a terminal with",
1578
+ ` oneteam-apps secret set ${first} --env ${firstEnv}`,
1579
+ " The value is typed at a hidden prompt. Never put it in the code, in oneteam.app.json or in a chat.",
1580
+ missing.live.length > 0 ? " The app cannot be approved or go live until the live values are there." : " The preview does not work fully until these are there."
1581
+ );
1582
+ return lines;
1583
+ }
1584
+ function describeSecret(secret, width) {
1585
+ const when2 = secret.updated_at ? secret.updated_at.replace("T", " ").slice(0, 16) : null;
1586
+ const details = [
1587
+ secret.host && `sent to ${secret.host}`,
1588
+ when2 && `entered ${when2}${secret.updated_by ? ` by ${secret.updated_by}` : ""}`,
1589
+ secret.status === "missing" && secret.new_in_version !== null && `new in version ${secret.new_in_version}`,
1590
+ secret.status === "unused" && "no longer declared by the app; remove it when you are sure"
1591
+ ].filter(Boolean);
1592
+ return ` ${secret.name.padEnd(width)} ${secret.status.padEnd(7)} ${details.join(", ")}`.trimEnd();
1593
+ }
1594
+ async function secretList(root, options, out2) {
1595
+ const print = options.print ?? ((text) => console.log(text));
1596
+ const only = envOption(options.appEnv);
1597
+ if (only === null) {
1598
+ out2.error('--env must be "live" or "preview"');
1599
+ return 1;
1600
+ }
1601
+ const found = await target2(root, options, out2);
1602
+ if (!found) return 1;
1603
+ try {
1604
+ const envs = only ? [only] : ENVS2;
1605
+ const results = await Promise.all(envs.map((env) => fetchSecrets(found.api, found.appPath, env)));
1606
+ if (results.some((result) => !result)) {
1607
+ out2.error("The API did not return the secrets.");
1608
+ return 1;
1609
+ }
1610
+ const byEnv = Object.fromEntries(envs.map((env, index) => [env, results[index]]));
1611
+ if (options.json) {
1612
+ print(JSON.stringify(byEnv, null, 2));
1613
+ return 0;
1614
+ }
1615
+ const known = new Set(results.flatMap((result) => result.secrets.map((secret) => secret.name)));
1616
+ const width = Math.max(4, ...[...known].map((name) => name.length));
1617
+ for (const env of envs) {
1618
+ const { secrets, blocked_version: blocked } = byEnv[env];
1619
+ out2.info(`${env}${blocked !== null ? ` (version ${blocked} is waiting for the missing values)` : ""}`);
1620
+ if (secrets.length === 0) out2.info(" none");
1621
+ for (const secret of secrets) out2.info(describeSecret(secret, width));
1622
+ }
1623
+ const local = found.project.manifest.secrets.map((secret) => secret.name).filter((name) => !known.has(name));
1624
+ if (local.length > 0) {
1625
+ out2.info("");
1626
+ out2.info(`In ${MANIFEST_FILE} but not deployed yet: ${local.join(", ")}. You can enter their values now, or after \`oneteam-apps deploy\`.`);
1627
+ }
1628
+ const missing = { preview: [], live: [] };
1629
+ for (const env of envs) missing[env] = byEnv[env].secrets.filter((secret) => secret.status === "missing").map((secret) => secret.name);
1630
+ for (const line of describeMissingSecrets(missing)) out2.info(line);
1631
+ return 0;
1632
+ } catch (error) {
1633
+ out2.error(error.message);
1634
+ return 1;
1635
+ }
1636
+ }
1637
+ async function secretSet(root, name, options, out2) {
1638
+ const problem = nameProblem(name, "set");
1639
+ if (problem) {
1640
+ out2.error(problem);
1641
+ return 1;
1642
+ }
1643
+ const env = envOption(options.appEnv);
1644
+ if (!env) {
1645
+ out2.error(env === null ? '--env must be "live" or "preview"' : ENV_REQUIRED);
1646
+ return 1;
1647
+ }
1648
+ const found = await target2(root, options, out2);
1649
+ if (!found) return 1;
1650
+ if (!found.project.manifest.secrets.some((secret) => secret.name === name)) {
1651
+ out2.error(
1652
+ `${name} is not listed under "secrets" in ${MANIFEST_FILE}. Add { "name": "${name}" } there first (a typo here would save a value the app never reads).`
1653
+ );
1654
+ return 1;
1655
+ }
1656
+ let value;
1657
+ try {
1658
+ value = await readSecretValue(`Value for ${name} (${env}), hidden: `, options.input ?? { stdin: process.stdin, stderr: process.stderr });
1659
+ } catch (error) {
1660
+ out2.error(error.message);
1661
+ return 1;
1662
+ }
1663
+ if (value.trim().length === 0) {
1664
+ out2.error("No value given. Nothing was saved.");
1665
+ return 1;
1666
+ }
1667
+ try {
1668
+ await found.api.put(`${found.appPath}/secrets/${encodeURIComponent(name)}?env=${env}`, { value });
1669
+ } catch (error) {
1670
+ out2.error(`Could not save ${name}: ${error.message.split(value).join("[value]")}`);
1671
+ return 1;
1672
+ }
1673
+ const other = env === "live" ? "preview" : "live";
1674
+ const missing = await missingSecrets(found.api, found.appPath);
1675
+ if (options.json) {
1676
+ (options.print ?? ((text) => console.log(text)))(JSON.stringify({ saved: true, name, env, missing }, null, 2));
1677
+ return 0;
1678
+ }
1679
+ out2.info(`Saved ${name} for ${env}. Nobody can read it back; to change it, set it again.`);
1680
+ if (missing?.[other].includes(name)) {
1681
+ out2.info(`${other} has no value for ${name} yet. It is entered separately: oneteam-apps secret set ${name} --env ${other}`);
1682
+ }
1683
+ const rest = missing && { preview: missing.preview.filter((item) => item !== name), live: missing.live.filter((item) => item !== name) };
1684
+ if (rest && rest.preview.length + rest.live.length > 0) {
1685
+ out2.info(`Still without a value: ${ENVS2.flatMap((item) => rest[item].map((secret) => `${secret} (${item})`)).join(", ")}`);
1686
+ }
1687
+ return 0;
1688
+ }
1689
+ async function secretRemove(root, name, options, out2) {
1690
+ const problem = nameProblem(name, "remove");
1691
+ if (problem) {
1692
+ out2.error(problem);
1693
+ return 1;
1694
+ }
1695
+ const env = envOption(options.appEnv);
1696
+ if (!env) {
1697
+ out2.error(env === null ? '--env must be "live" or "preview"' : ENV_REQUIRED);
1698
+ return 1;
1699
+ }
1700
+ const found = await target2(root, options, out2);
1701
+ if (!found) return 1;
1702
+ try {
1703
+ await found.api.delete(`${found.appPath}/secrets/${encodeURIComponent(name)}?env=${env}`);
1704
+ } catch (error) {
1705
+ out2.error(`Could not remove ${name}: ${error.message}`);
1706
+ return 1;
1707
+ }
1708
+ if (options.json) {
1709
+ (options.print ?? ((text) => console.log(text)))(JSON.stringify({ removed: true, name, env }, null, 2));
1710
+ return 0;
1711
+ }
1712
+ out2.info(`Removed the ${env} value of ${name}.`);
1713
+ if (found.project.manifest.secrets.some((secret) => secret.name === name)) {
1714
+ out2.warn(`${name} is still listed in ${MANIFEST_FILE}: the app needs a value for it in ${env}.`);
1715
+ }
1716
+ return 0;
1717
+ }
1718
+ var USAGE2 = "Usage: oneteam-apps secret list|set|remove [NAME] --env live|preview";
1719
+ async function secretCommand(root, args, base, out2, quiet2 = out2) {
1720
+ const [action, ...rest] = args;
1721
+ if (rest.some((arg) => /^--?(?:value|secret|val)(?:=|$)/i.test(arg))) {
1722
+ out2.error(
1723
+ "A secret's value is never passed on the command line. Run `oneteam-apps secret set NAME --env live|preview` and type it at the hidden prompt, or pipe it in."
1724
+ );
1725
+ return 1;
1726
+ }
1727
+ let parsed;
1728
+ try {
1729
+ parsed = parseArgs2({
1730
+ args: rest,
1731
+ allowPositionals: true,
1732
+ options: { api: { type: "string" }, organisation: { type: "string" }, json: { type: "boolean" }, env: { type: "string" } }
1733
+ });
1734
+ } catch {
1735
+ out2.error(`Unknown option. ${USAGE2}`);
1736
+ return 1;
1737
+ }
1738
+ const { values, positionals } = parsed;
1739
+ const options = { ...base, api: values.api, organisation: values.organisation, json: values.json, appEnv: values.env };
1740
+ const output = values.json ? quiet2 : out2;
1741
+ if (action === void 0 || action === "list") {
1742
+ if (positionals.length > 0) {
1743
+ out2.error(USAGE2);
1744
+ return 1;
1745
+ }
1746
+ return secretList(root, options, output);
1747
+ }
1748
+ if (action === "set" || action === "remove") {
1749
+ if (positionals.length > 1) {
1750
+ out2.error(
1751
+ action === "set" ? "Only the name goes on the command line, never the value: `oneteam-apps secret set NAME --env live|preview`, then type the value at the hidden prompt." : "One name at a time: oneteam-apps secret remove NAME --env live|preview"
1752
+ );
1753
+ return 1;
1754
+ }
1755
+ return action === "set" ? secretSet(root, positionals[0], options, output) : secretRemove(root, positionals[0], options, output);
1756
+ }
1757
+ out2.error(USAGE2);
1758
+ return 1;
1759
+ }
1760
+
1761
+ // src/hosted/deploy.ts
1762
+ function sleep(ms) {
1763
+ return new Promise((resolve2) => setTimeout(resolve2, ms));
1764
+ }
1765
+ function indent(text, by) {
1766
+ return text.split("\n").join(`
1767
+ ${by}`);
1768
+ }
1769
+ function formatReview(version) {
1770
+ const lines = [];
1771
+ const review = version.review;
1772
+ if (!review) return lines;
1773
+ if (review.checks.length > 0) {
1774
+ lines.push("", "Checks");
1775
+ for (const check of review.checks) {
1776
+ lines.push(` ${check.passed ? "PASS" : "FAIL"} ${check.title}${check.detail ? ` (${check.detail})` : ""}`);
1777
+ }
1778
+ }
1779
+ const open = review.findings.filter((finding) => !finding.resolved);
1780
+ if (open.length > 0) {
1781
+ lines.push("", "Findings");
1782
+ for (const finding of open) {
1783
+ const where = finding.file ? `${finding.file}${finding.line ? `:${finding.line}` : ""}` : "(whole app)";
1784
+ lines.push(` ${finding.severity.toUpperCase().padEnd(6)} ${where}`);
1785
+ lines.push(` ${finding.title}`);
1786
+ if (finding.detail) lines.push(` ${indent(finding.detail, " ")}`);
1787
+ if (finding.fix) lines.push(` Fix: ${indent(finding.fix, " ")}`);
1788
+ }
1789
+ }
1790
+ const data = review.data_summary;
1791
+ const rows = data ? [
1792
+ ["Stores", data.stores],
1793
+ ["Personal data", data.personal_data],
1794
+ ["Reads", data.reads],
1795
+ ["Changes", data.changes],
1796
+ ["Sends outside", data.sends_outside]
1797
+ ].filter(([, values]) => values.length > 0) : [];
1798
+ if (rows.length > 0) {
1799
+ lines.push("", "What the review understood about the data");
1800
+ for (const [label, values] of rows) lines.push(` ${`${label}:`.padEnd(15)}${values.join("; ")}`);
1801
+ }
1802
+ return lines;
1803
+ }
1804
+ function manifestForUpload(raw, manifest2) {
1805
+ const suggested = manifest2.suggested_audience;
1806
+ const audience = suggested && (suggested.network_ids.length > 0 || suggested.function_ids.length > 0) ? { network_ids: suggested.network_ids, function_ids: suggested.function_ids } : void 0;
1807
+ return JSON.stringify({ ...raw, ...audience && { audience } });
1808
+ }
1809
+ async function waitForReview(api, appPath, version, options, out2) {
1810
+ if (version.status !== "reviewing") return { version, app: null };
1811
+ out2.info("Review in progress\u2026");
1812
+ const deadline = Date.now() + (options.reviewTimeoutMs ?? 5 * 6e4);
1813
+ while (Date.now() < deadline) {
1814
+ await sleep(options.pollMs ?? 3e3);
1815
+ const app = parseHostedApp(await api.get(appPath));
1816
+ if (!app) continue;
1817
+ const reviewed = [app.latest_version, app.preview_version, app.live_version].find((candidate) => candidate?.id === version.id);
1818
+ if (reviewed && reviewed.status !== "reviewing") return { version: reviewed, app };
1819
+ if (app.status === "review_failed") {
1820
+ return { version: { ...version, status: "review_failed", review: version.review ?? { passed: false, checks: [], findings: [], data_summary: null } }, app };
1821
+ }
1822
+ }
1823
+ return { version, app: null };
1824
+ }
1825
+ async function deploy(root, options, out2) {
1826
+ const print = options.print ?? ((text) => console.log(text));
1827
+ const say = options.json ? () => {
1828
+ } : (line) => out2.info(line);
1829
+ const fail = (message, extra = {}) => {
1830
+ if (options.json) print(JSON.stringify({ deployed: false, error: message, ...extra }, null, 2));
1831
+ else out2.error(message);
1832
+ return 1;
1833
+ };
1834
+ const project = readProject(root, options.json ? { ...out2, error: () => {
1835
+ } } : out2);
1836
+ if (!project) return options.json ? fail(`${MANIFEST_FILE} is missing or invalid. Run \`oneteam-apps validate\`.`) : 1;
1837
+ const api = await connect(options, options.json ? { ...out2, error: () => {
1838
+ } } : out2);
1839
+ if (!api) return options.json ? fail("Not signed in. Run `oneteam-apps login`, or set ONETEAM_TOKEN.") : 1;
1840
+ const organisationId = organisationFor(options, project.manifest, api.apiUrl);
1841
+ if (!organisationId) return fail(NO_ORGANISATION);
1842
+ const jobs = checkJobsAndSends(root, project.manifest);
1843
+ if (jobs.errors.length > 0) return fail(jobs.errors.join("\n"), { problems: jobs.errors });
1844
+ if (!options.json) for (const warning of jobs.warnings) out2.warn(warning);
1845
+ let bundle;
1846
+ try {
1847
+ bundle = await createBundle(root, { ...options, log: say });
1848
+ } catch (error) {
1849
+ return fail(error.message);
1850
+ }
1851
+ const design = findUiWarnings(root).length;
1852
+ if (design > 0 && !options.json) {
1853
+ out2.warn(`${design} place(s) in src/client use a written-out colour or a hand-made button, input or dialog. Not a blocker; \`oneteam-apps doctor\` lists them.`);
1854
+ }
1855
+ say(
1856
+ `Packed ${bundle.files.assets} asset file(s), ${bundle.files.migrations} migration(s) and ${bundle.files.source} source file(s) (${(bundle.archive.length / 1024).toFixed(0)} KB).`
1857
+ );
1858
+ let { manifest: manifest2, raw } = project;
1859
+ const summary = options.summary ?? manifest2.summary;
1860
+ if (options.summary) raw = { ...raw, summary: options.summary };
1861
+ try {
1862
+ if (!manifest2.id) {
1863
+ const registered = await api.post(`/v1/organisations/${encodeURIComponent(organisationId)}/apps`, {
1864
+ hosting: "hosted",
1865
+ name: manifest2.name,
1866
+ slug: manifest2.slug,
1867
+ ...manifest2.description && { description: manifest2.description },
1868
+ // What the gateway asks for at sign-in, unless the app narrows it down.
1869
+ scopes: manifest2.scopes ?? [...HOSTED_APP_SCOPES],
1870
+ manifest: raw
1871
+ });
1872
+ const appId = registered?.app_id ?? registered?.id;
1873
+ if (appId === void 0 || appId === null) throw new Error("The API registered the app but did not return its id");
1874
+ raw = { ...raw, id: String(appId), organisation_id: organisationId };
1875
+ manifest2 = { ...manifest2, id: String(appId), organisation_id: organisationId };
1876
+ writeFileSync3(join7(root, MANIFEST_FILE), `${JSON.stringify(raw, null, 2)}
1877
+ `);
1878
+ say(`Registered "${manifest2.name}" (id ${manifest2.id}); saved the id in ${MANIFEST_FILE}.`);
1879
+ }
1880
+ const appPath = `/v1/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(manifest2.id)}`;
1881
+ const form = new FormData();
1882
+ form.set("manifest", manifestForUpload(raw, manifest2));
1883
+ if (summary) form.set("summary", summary);
1884
+ form.set("bundle", new Blob([bundle.archive], { type: "application/gzip" }), "bundle.tar.gz");
1885
+ say("Uploading\u2026");
1886
+ const response = await api.upload(`${appPath}/versions`, form);
1887
+ const uploaded = parseAppVersion(response);
1888
+ if (!uploaded) {
1889
+ return fail("The upload was accepted, but the answer was not a version. Check the result with `oneteam-apps status`.", { response });
1890
+ }
1891
+ const { version, app: polled } = await waitForReview(api, appPath, uploaded, options, options.json ? { ...out2, info: () => {
1892
+ } } : out2);
1893
+ const review = version.review;
1894
+ const label = version.number === null ? "This upload" : `Version ${version.number}`;
1895
+ if (version.status === "reviewing") {
1896
+ if (options.json) print(JSON.stringify({ deployed: false, reviewing: true, app_id: manifest2.id, version }, null, 2));
1897
+ else out2.warn(`${label} of "${manifest2.name}" is still being reviewed. Check again with \`oneteam-apps status\`.`);
1898
+ return 1;
1899
+ }
1900
+ const deployed = review?.passed !== false && (version.status === "preview" || version.status === "live");
1901
+ if (!deployed) {
1902
+ const open = review?.findings.filter((finding) => !finding.resolved) ?? [];
1903
+ const failedChecks = review?.checks.filter((check) => !check.passed) ?? [];
1904
+ if (options.json) {
1905
+ print(JSON.stringify({ deployed: false, app_id: manifest2.id, version }, null, 2));
1906
+ } else {
1907
+ out2.info(`${label} of "${manifest2.name}" did not pass the review.`);
1908
+ for (const line of formatReview(version)) out2.info(line);
1909
+ if (version.rejection_reason) out2.info(`
1910
+ Reason: ${version.rejection_reason}`);
1911
+ out2.info("");
1912
+ out2.error(
1913
+ `Deploy stopped: ${open.length > 0 ? `${open.length === 1 ? "1 finding" : `${open.length} findings`} to fix` : failedChecks.length > 0 ? `${failedChecks.length === 1 ? "1 check" : `${failedChecks.length} checks`} failed` : `the version is "${version.status}"`}. Nothing changed for the people using the app. Fix what is listed above and run \`oneteam-apps deploy\` again.`
1914
+ );
1915
+ }
1916
+ return 1;
1917
+ }
1918
+ const app = polled ?? parseHostedApp(await api.get(appPath).catch(() => null));
1919
+ const address = version.status === "live" ? app?.url : app?.preview_url;
1920
+ const missing = manifest2.secrets.length > 0 ? await missingSecrets(api, appPath) : null;
1921
+ const approval = manifest2.schedules.length + manifest2.sends.length > 0 ? needsApproval(manifest2, await fetchSchedules(api, appPath, "live")) : [];
1922
+ if (options.json) {
1923
+ print(
1924
+ JSON.stringify(
1925
+ {
1926
+ deployed: true,
1927
+ app_id: manifest2.id,
1928
+ status: app?.status ?? null,
1929
+ preview_url: app?.preview_url ?? null,
1930
+ url: app?.url ?? null,
1931
+ needs_approval: version.needs_approval,
1932
+ ...missing && { secrets_missing: missing },
1933
+ ...approval.length > 0 && { needs_approval_for: approval },
1934
+ version
1935
+ },
1936
+ null,
1937
+ 2
1938
+ )
1939
+ );
1940
+ return 0;
1941
+ }
1942
+ out2.info(`${label} of "${manifest2.name}" passed the review.`);
1943
+ for (const line of formatReview(version)) out2.info(line);
1944
+ out2.info("");
1945
+ out2.info(`${version.status === "live" ? "Live" : "Preview"}: ${address ?? "(run `oneteam-apps status` for the address)"}`);
1946
+ out2.info(
1947
+ version.needs_approval ? "Waiting for approval: an organisation admin has to approve this version in the dashboard before it goes live. Until then only you and invited testers can open the preview." : version.status === "live" ? "This version is live." : "No approval needed for this version."
1948
+ );
1949
+ if (approval.length > 0) {
1950
+ out2.info("");
1951
+ out2.info("An admin has to approve this before it starts:");
1952
+ for (const reason of approval) out2.info(` ${reason}`);
1953
+ out2.info(" Until then the live app keeps its current jobs and what it may send. Try a job in the preview: oneteam-apps job run NAME --env preview");
1954
+ }
1955
+ if (missing) for (const line of describeMissingSecrets(missing)) out2.info(line);
1956
+ else if (manifest2.secrets.length > 0) {
1957
+ out2.info("");
1958
+ out2.info(`This app declares ${manifest2.secrets.length === 1 ? "a secret" : "secrets"} (${manifest2.secrets.map((secret) => secret.name).join(", ")}). Check that each has a value: oneteam-apps secret list`);
1959
+ }
1960
+ return 0;
1961
+ } catch (error) {
1962
+ return fail(error.message);
1963
+ }
1964
+ }
1965
+ function describeVersion(label, version) {
1966
+ if (!version) return `${label}: none`;
1967
+ return `${label}: version ${version.number ?? "?"} (${version.status.replace(/_/g, " ")}${version.needs_approval ? ", waiting for approval" : ""})${version.summary ? `: ${version.summary}` : ""}`;
1968
+ }
1969
+ async function status(root, options, out2) {
1970
+ const print = options.print ?? ((text) => console.log(text));
1971
+ const project = readProject(root, out2);
1972
+ if (!project) return 1;
1973
+ if (!project.manifest.id) {
1974
+ out2.error("This app has not been deployed yet. Run `oneteam-apps deploy`.");
1975
+ return 1;
1976
+ }
1977
+ const api = await connect(options, out2);
1978
+ if (!api) return 1;
1979
+ const organisationId = organisationFor(options, project.manifest, api.apiUrl);
1980
+ if (!organisationId) {
1981
+ out2.error(NO_ORGANISATION);
1982
+ return 1;
1983
+ }
1984
+ try {
1985
+ const app = parseHostedApp(
1986
+ await api.get(`/v1/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(project.manifest.id)}`)
1987
+ );
1988
+ if (!app) {
1989
+ out2.error("The API did not return the app.");
1990
+ return 1;
1991
+ }
1992
+ const missing = await missingSecrets(api, `/v1/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(project.manifest.id)}`);
1993
+ if (options.json) {
1994
+ print(JSON.stringify({ ...app, ...missing && { secrets_missing: missing } }, null, 2));
1995
+ return 0;
1996
+ }
1997
+ out2.info(`${app.name}: ${app.status.replace(/_/g, " ")}`);
1998
+ out2.info(`Live address: ${app.url ?? "none"}`);
1999
+ out2.info(`Preview address: ${app.preview_url ?? "none"}`);
2000
+ out2.info(describeVersion("Live", app.live_version));
2001
+ out2.info(describeVersion("Preview", app.preview_version));
2002
+ const latest = app.latest_version;
2003
+ if (latest && latest.id !== app.preview_version?.id && latest.id !== app.live_version?.id) {
2004
+ out2.info(`Latest upload: ${latest.status.replace(/_/g, " ")}${latest.rejection_reason ? ` (${latest.rejection_reason})` : ""}`);
2005
+ if (latest.review && !latest.review.passed) for (const line of formatReview(latest)) out2.info(line);
2006
+ }
2007
+ if (missing) for (const line of describeMissingSecrets(missing)) out2.info(line);
2008
+ const appPath = `/v1/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(project.manifest.id)}`;
2009
+ const live = await fetchSchedules(api, appPath, "live");
2010
+ const lines = live ? describeSchedules("live", live) : [];
2011
+ if (lines.length > 0) {
2012
+ out2.info("");
2013
+ for (const line of lines) out2.info(line);
2014
+ }
2015
+ if (app.preview_version && project.manifest.schedules.length + project.manifest.sends.length > 0) {
2016
+ const approval = needsApproval(project.manifest, live);
2017
+ if (approval.length > 0) {
2018
+ out2.info("");
2019
+ out2.info(`Waiting for an admin's approval before it starts: ${approval.join("; ")}`);
2020
+ }
2021
+ }
2022
+ return 0;
2023
+ } catch (error) {
2024
+ out2.error(error.message);
2025
+ return 1;
2026
+ }
2027
+ }
2028
+ function formatEvent(event) {
2029
+ const time = event.created_at ? event.created_at.replace("T", " ").slice(0, 19) : "";
2030
+ const head = `${time} ${event.env.padEnd(7)} ${String(event.status ?? "").padEnd(3)} ${(event.method ?? "").padEnd(6)} ${event.path ?? ""}`;
2031
+ if (event.type !== "error") return `${head} ${event.duration_ms ?? 0}ms`;
2032
+ return [head, ...event.message ? [` ${indent(event.message, " ")}`] : [], ...event.stack ? [` ${indent(event.stack, " ")}`] : []].join("\n");
2033
+ }
2034
+ async function logs(root, options, out2) {
2035
+ const print = options.print ?? ((text) => console.log(text));
2036
+ const project = readProject(root, out2);
2037
+ if (!project) return 1;
2038
+ if (!project.manifest.id) {
2039
+ out2.error("This app has not been deployed yet. Run `oneteam-apps deploy`.");
2040
+ return 1;
2041
+ }
2042
+ if (options.appEnv && options.appEnv !== "live" && options.appEnv !== "preview") {
2043
+ out2.error('--env must be "live" or "preview"');
2044
+ return 1;
2045
+ }
2046
+ const hours = Number(options.hours ?? 24);
2047
+ if (!Number.isFinite(hours) || hours <= 0) {
2048
+ out2.error("--hours must be a positive number");
2049
+ return 1;
2050
+ }
2051
+ const api = await connect(options, out2);
2052
+ if (!api) return 1;
2053
+ const organisationId = organisationFor(options, project.manifest, api.apiUrl);
2054
+ if (!organisationId) {
2055
+ out2.error(NO_ORGANISATION);
2056
+ return 1;
2057
+ }
2058
+ try {
2059
+ const query = new URLSearchParams({ ...options.appEnv && { env: options.appEnv }, hours: String(hours) });
2060
+ const result = await api.get(
2061
+ `/v1/organisations/${encodeURIComponent(organisationId)}/apps/${encodeURIComponent(project.manifest.id)}/logs?${query}`
2062
+ );
2063
+ const errors = result?.errors ?? [];
2064
+ const requests = result?.requests ?? [];
2065
+ if (options.json) {
2066
+ print(JSON.stringify({ errors, requests }, null, 2));
2067
+ return 0;
2068
+ }
2069
+ out2.info(`Last ${hours} hour(s)${options.appEnv ? `, ${options.appEnv}` : ""}: ${errors.length} error(s), ${requests.length} request(s)`);
2070
+ if (errors.length > 0) {
2071
+ out2.info("");
2072
+ out2.info("Errors");
2073
+ for (const event of errors) out2.info(formatEvent(event));
2074
+ }
2075
+ if (requests.length > 0) {
2076
+ out2.info("");
2077
+ out2.info("Requests");
2078
+ for (const event of requests.slice(0, 100)) out2.info(formatEvent(event));
2079
+ if (requests.length > 100) out2.info(`\u2026 and ${requests.length - 100} more (use --json for all of them)`);
2080
+ }
2081
+ return 0;
2082
+ } catch (error) {
2083
+ out2.error(error.message);
2084
+ return 1;
2085
+ }
2086
+ }
2087
+
2088
+ // src/hosted/init.ts
2089
+ import { cpSync, existsSync as existsSync7, mkdirSync as mkdirSync3, readdirSync as readdirSync4, readFileSync as readFileSync7, renameSync, writeFileSync as writeFileSync4 } from "fs";
2090
+ import { dirname as dirname3, join as join8, relative as relative3, resolve } from "path";
2091
+ import { fileURLToPath } from "url";
2092
+ import { APP_SLUG } from "@oneteam-app/apps/contracts";
2093
+ function templateDirectory() {
2094
+ const here = dirname3(fileURLToPath(import.meta.url));
2095
+ const found = [join8(here, "..", "templates", "hosted"), join8(here, "..", "..", "templates", "hosted")].find(
2096
+ (candidate) => existsSync7(join8(candidate, "package.json"))
2097
+ );
2098
+ if (!found) throw new Error("The hosted template is missing from this installation of the CLI");
2099
+ return found;
2100
+ }
2101
+ function slugify(name) {
2102
+ return name.normalize("NFKD").replace(/[̀-ͯ]/g, "").toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-+|-+$/g, "").slice(0, 40).replace(/-+$/, "");
2103
+ }
2104
+ function cliVersion() {
2105
+ return true ? "0.1.0-alpha.7" : "0.0.0";
2106
+ }
2107
+ function replaceIn(path, edit) {
2108
+ writeFileSync4(path, edit(readFileSync7(path, "utf8")));
2109
+ }
2110
+ function initHosted(root, name, options, out2) {
2111
+ const slug = slugify(name);
2112
+ if (slug.length < 2 || !APP_SLUG.test(slug)) {
2113
+ out2.error(`"${name}" does not make a usable address. Use a name with at least two letters or digits.`);
2114
+ return 1;
2115
+ }
2116
+ const target3 = resolve(root, options.dir ?? slug);
2117
+ if (existsSync7(target3) && readdirSync4(target3).length > 0 && !options.force) {
2118
+ out2.error(`${relative3(root, target3) || "."} already exists and is not empty. Pick another name, pass --dir, or use --force.`);
2119
+ return 1;
2120
+ }
2121
+ mkdirSync3(target3, { recursive: true });
2122
+ cpSync(templateDirectory(), target3, { recursive: true });
2123
+ if (existsSync7(join8(target3, "_gitignore"))) renameSync(join8(target3, "_gitignore"), join8(target3, ".gitignore"));
2124
+ const version = `^${cliVersion()}`;
2125
+ replaceIn(join8(target3, "package.json"), (contents) => {
2126
+ const pkg = JSON.parse(contents);
2127
+ pkg.name = slug;
2128
+ pkg.dependencies["@oneteam-app/apps"] = options.sdkVersion ?? version;
2129
+ pkg.dependencies["@oneteam-app/ui"] = options.uiVersion ?? version;
2130
+ pkg.devDependencies["@oneteam-app/apps-cli"] = options.cliVersion ?? version;
2131
+ return `${JSON.stringify(pkg, null, 2)}
2132
+ `;
2133
+ });
2134
+ replaceIn(join8(target3, MANIFEST_FILE), (contents) => {
2135
+ const manifest2 = JSON.parse(contents);
2136
+ return `${JSON.stringify(
2137
+ {
2138
+ ...manifest2,
2139
+ name,
2140
+ slug,
2141
+ description: "",
2142
+ summary: "First version.",
2143
+ // What the template stores; update it when the app stores something else.
2144
+ personal_data: manifest2.personal_data,
2145
+ suggested_audience: { description: "" }
2146
+ },
2147
+ null,
2148
+ 2
2149
+ )}
2150
+ `;
2151
+ });
2152
+ replaceIn(
2153
+ join8(target3, "wrangler.jsonc"),
2154
+ (contents) => contents.replace('"name": "lost-and-found"', `"name": "${slug}"`).replace('"database_name": "lost-and-found"', `"database_name": "${slug}"`)
2155
+ );
2156
+ replaceIn(join8(target3, "README.md"), (contents) => contents.replace("# Lost and found (hosted app template)", `# ${name}`));
2157
+ out2.info(`created ${relative3(root, target3) || "."} from the hosted template`);
2158
+ writeAgentSection(target3, HOSTED_AGENT_INSTRUCTIONS, out2);
2159
+ const cursorRule = join8(target3, ".cursor", "rules", "oneteam-apps.mdc");
2160
+ mkdirSync3(dirname3(cursorRule), { recursive: true });
2161
+ writeFileSync4(cursorRule, HOSTED_CURSOR_RULE);
2162
+ out2.info("created .cursor/rules/oneteam-apps.mdc");
2163
+ out2.info("");
2164
+ out2.info("Next steps:");
2165
+ out2.info(` 1. cd ${relative3(root, target3) || "."} && npm install`);
2166
+ out2.info(' 2. npm run dev the app with a "View as" bar instead of sign-on, no network needed');
2167
+ out2.info(" 3. Replace the example feature (a lost and found log) with yours; keep the permission tests passing: npm test");
2168
+ out2.info(" 4. npx oneteam-apps login && npx oneteam-apps deploy --organisation <id>");
2169
+ return 0;
2170
+ }
375
2171
 
376
2172
  // src/index.ts
377
- var HELP = `oneteam-apps: Oneteam login for your app
2173
+ var HELP = `oneteam-apps: build apps for Oneteam
378
2174
 
379
- Usage
2175
+ Hosted apps (the platform runs the app and signs people in)
2176
+ oneteam-apps init --hosted <name> [--dir <folder>] [--force]
2177
+ oneteam-apps login [--organisation <id>] [--no-browser]
2178
+ oneteam-apps deploy [--organisation <id>] [--summary <what changed>] [--json]
2179
+ oneteam-apps status [--json]
2180
+ oneteam-apps logs [--env live|preview] [--hours <n>] [--json]
2181
+ oneteam-apps job list [--env live|preview] [--json]
2182
+ oneteam-apps job run <NAME> --env live|preview
2183
+ oneteam-apps secret list [--env live|preview] [--json]
2184
+ oneteam-apps secret set <NAME> --env live|preview
2185
+ oneteam-apps secret remove <NAME> --env live|preview
2186
+
2187
+ Apps you host yourself (adds Oneteam login to a Next.js or TanStack Start app)
380
2188
  oneteam-apps init [--name <app name>] [--url <production url>] [--force]
2189
+
2190
+ Both
381
2191
  oneteam-apps validate [file]
382
2192
  oneteam-apps doctor [--offline]
383
2193
 
384
2194
  Commands
385
- init Add Oneteam login to a Next.js App Router project (non-interactive)
2195
+ init Start a hosted app from the template, or add Oneteam login to an existing app
2196
+ login Sign in to deploy. Not needed when ONETEAM_TOKEN is set (a personal access token)
2197
+ deploy Build, upload and review the app. Prints what to fix, or the preview address.
2198
+ Exit code 0: deployed. 1: stopped
2199
+ status Where the app stands: status, addresses, live and preview version
2200
+ logs Recent errors and requests of the running app
2201
+ job Scheduled jobs ("schedules" in oneteam.app.json): what runs when, how the
2202
+ last run went, and running one now. A job run by hand does everything it
2203
+ does on its schedule, sending included
2204
+ secret Values the app needs but must not contain (an API key for another service).
2205
+ The app names them under "secrets" in oneteam.app.json; a person enters each
2206
+ value once per environment. \`set\` asks for it at a hidden prompt, or reads
2207
+ stdin when piped. There is no --value flag, and no way to read a value back
386
2208
  validate Check oneteam.app.json against the manifest schema
387
- doctor Check manifest, environment, route protection and connectivity
2209
+ doctor Check the project's setup
2210
+
2211
+ Environment
2212
+ ONETEAM_TOKEN Token for the API, instead of \`login\`
2213
+ ONETEAM_ORGANISATION_ID The organisation to register a new app in
2214
+ ONETEAM_API_URL Defaults to https://api.oneteam.io
388
2215
  `;
389
2216
  var out = {
390
2217
  info: (message) => console.log(message),
391
2218
  warn: (message) => console.warn(`warning: ${message}`),
392
2219
  error: (message) => console.error(`error: ${message}`)
393
2220
  };
2221
+ var quiet = { ...out, info: (message) => console.error(message) };
2222
+ var shared = {
2223
+ api: { type: "string" },
2224
+ organisation: { type: "string" },
2225
+ json: { type: "boolean" }
2226
+ };
394
2227
  async function main(argv) {
395
2228
  const [command, ...rest] = argv;
396
2229
  const root = process.cwd();
397
- if (!command || command === "--help" || command === "-h") {
2230
+ const env = process.env;
2231
+ if (!command || command === "--help" || command === "-h" || command === "help") {
398
2232
  console.log(HELP);
399
2233
  return command ? 0 : 1;
400
2234
  }
401
2235
  if (command === "init") {
402
- const { values } = parseArgs({
2236
+ const { values, positionals } = parseArgs3({
403
2237
  args: rest,
404
- options: { name: { type: "string" }, url: { type: "string" }, force: { type: "boolean" } }
2238
+ allowPositionals: true,
2239
+ options: {
2240
+ name: { type: "string" },
2241
+ url: { type: "string" },
2242
+ force: { type: "boolean" },
2243
+ // With a value: the app's name. `--hosted` alone takes the first positional.
2244
+ hosted: { type: "boolean" },
2245
+ dir: { type: "string" },
2246
+ "sdk-version": { type: "string" },
2247
+ "ui-version": { type: "string" },
2248
+ "cli-version": { type: "string" }
2249
+ }
405
2250
  });
2251
+ if (values.hosted) {
2252
+ const name = positionals.join(" ") || values.name;
2253
+ if (!name) {
2254
+ out.error('Give the app a name: oneteam-apps init --hosted "Lost and found"');
2255
+ return 1;
2256
+ }
2257
+ return initHosted(root, name, { dir: values.dir, force: values.force, sdkVersion: values["sdk-version"], cliVersion: values["cli-version"], uiVersion: values["ui-version"] }, out);
2258
+ }
406
2259
  return init(root, values, out);
407
2260
  }
408
2261
  if (command === "validate") {
409
- const { positionals } = parseArgs({ args: rest, allowPositionals: true });
2262
+ const { positionals } = parseArgs3({ args: rest, allowPositionals: true });
410
2263
  return validate(root, positionals[0], out).code;
411
2264
  }
412
2265
  if (command === "doctor") {
413
- const { values } = parseArgs({ args: rest, options: { offline: { type: "boolean" } } });
414
- return doctor(root, { ...values, env: process.env }, out);
2266
+ const { values } = parseArgs3({ args: rest, options: { offline: { type: "boolean" } } });
2267
+ return doctor(root, { ...values, env }, out);
2268
+ }
2269
+ if (command === "login") {
2270
+ const { values } = parseArgs3({
2271
+ args: rest,
2272
+ options: { api: shared.api, organisation: shared.organisation, "no-browser": { type: "boolean" }, status: { type: "boolean" } }
2273
+ });
2274
+ const apiUrl = apiUrlFrom(env, values.api);
2275
+ if (values.status) {
2276
+ out.info(`${apiUrl}: ${describeAuth(env, apiUrl)}`);
2277
+ return 0;
2278
+ }
2279
+ if (env.ONETEAM_TOKEN) out.warn("ONETEAM_TOKEN is set and takes precedence over what you sign in with here.");
2280
+ return login({ env, apiUrl, organisationId: values.organisation, noBrowser: values["no-browser"] }, out);
2281
+ }
2282
+ if (command === "deploy") {
2283
+ const { values } = parseArgs3({
2284
+ args: rest,
2285
+ options: {
2286
+ ...shared,
2287
+ summary: { type: "string" },
2288
+ worker: { type: "string" },
2289
+ assets: { type: "string" },
2290
+ "skip-build": { type: "boolean" }
2291
+ }
2292
+ });
2293
+ return deploy(root, { ...values, env, skipBuild: values["skip-build"] }, values.json ? quiet : out);
2294
+ }
2295
+ if (command === "status") {
2296
+ const { values } = parseArgs3({ args: rest, options: shared });
2297
+ return status(root, { ...values, env }, values.json ? quiet : out);
2298
+ }
2299
+ if (command === "logs") {
2300
+ const { values } = parseArgs3({ args: rest, options: { ...shared, env: { type: "string" }, hours: { type: "string" } } });
2301
+ return logs(root, { api: values.api, organisation: values.organisation, json: values.json, appEnv: values.env, hours: values.hours, env }, values.json ? quiet : out);
415
2302
  }
2303
+ if (command === "secret" || command === "secrets") return secretCommand(root, rest, { env }, out, quiet);
2304
+ if (command === "job" || command === "jobs") return jobCommand(root, rest, { env }, out, quiet);
416
2305
  out.error(`unknown command "${command}"`);
417
2306
  console.log(HELP);
418
2307
  return 1;