@runeditrun/rer 0.2.2 → 0.2.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +9 -2
- package/cli/README.md +21 -0
- package/cli/container.mjs +11 -10
- package/cli/contract.mjs +2 -2
- package/cli/demo-host.mjs +47 -0
- package/cli/demo.mjs +47 -0
- package/cli/dependencies.mjs +26 -4
- package/cli/deploy-command.mjs +94 -0
- package/cli/deploy-inputs.mjs +22 -0
- package/cli/deploy-observation.mjs +28 -0
- package/cli/deploy-owner.mjs +52 -37
- package/cli/deploy-readiness.mjs +16 -0
- package/cli/deploy.mjs +20 -10
- package/cli/detached-server.mjs +1 -1
- package/cli/export.mjs +1 -1
- package/cli/hosted-output.mjs +2 -1
- package/cli/lifecycle.mjs +20 -7
- package/cli/local.mjs +7 -0
- package/cli/main.mjs +1 -1
- package/cli/managed-server.mjs +27 -18
- package/cli/parse.mjs +7 -2
- package/cli/pnpm-probe.mjs +4 -1
- package/cli/portability.mjs +4 -1
- package/cli/scheduled.mjs +2 -2
- package/cli/setup-input.mjs +1 -1
- package/global/CHANGELOG.md +46 -0
- package/global/README.md +9 -2
- package/global/launch/capacity.mjs +1 -1
- package/global/launch/facts.mjs +12 -6
- package/global/launch/install-pinned.mjs +26 -2
- package/global/launch/prepare.mjs +20 -8
- package/global/launch/state.mjs +54 -1
- package/global/main.mjs +18 -20
- package/global/update/command.mjs +2 -1
- package/modules/container/build.mjs +4 -2
- package/modules/container/host.mjs +1 -1
- package/modules/container/ingress.mjs +4 -1
- package/modules/container/migrations.mjs +2 -1
- package/modules/container/worker-engine.mjs +3 -1
- package/modules/demo-host/README.md +30 -0
- package/modules/demo-host/capacity.mjs +19 -0
- package/modules/demo-host/claim.mjs +21 -0
- package/modules/demo-host/container.mjs +74 -0
- package/modules/demo-host/pages.mjs +56 -0
- package/modules/demo-host/plan.mjs +52 -0
- package/modules/demo-host/worker.mjs +72 -0
- package/modules/deploy-kit/README.md +27 -0
- package/modules/deploy-kit/env-file.mjs +2 -1
- package/modules/deploy-kit/health-request.mjs +7 -2
- package/modules/deploy-kit/migration-ledger.mjs +38 -1
- package/modules/deploy-kit/migrations.mjs +38 -2
- package/modules/deploy-kit/owner-cli.mjs +30 -9
- package/modules/deploy-kit/owner-remove.mjs +5 -2
- package/modules/deploy-kit/owner-secret.mjs +67 -22
- package/modules/deploy-kit/owner.mjs +71 -24
- package/modules/deploy-kit/prepare.mjs +3 -1
- package/modules/deploy-kit/provider.mjs +3 -0
- package/modules/erasure/README.md +2 -3
- package/modules/operator-auth/accounts/settings-page.mjs +8 -3
- package/modules/operator-auth/csp.mjs +26 -0
- package/modules/operator-auth/dev/badge.mjs +1 -22
- package/modules/operator-auth/page.mjs +2 -2
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +9 -0
- package/modules/portability/bridge.mjs +4 -1
- package/modules/portability/catalogue.mjs +9 -2
- package/modules/portability/index.d.mts +4 -0
- package/modules/portability/index.mjs +1 -0
- package/modules/portability/lines.mjs +3 -1
- package/modules/release/produce.mjs +1 -1
- package/modules/release/source.mjs +5 -2
- package/onboarding/adapter-labels.mjs +1 -0
- package/onboarding/adapter.mjs +9 -4
- package/onboarding/dependency-environment.mjs +33 -0
- package/onboarding/local/owner.mjs +1 -1
- package/onboarding/operator-installer.mjs +10 -4
- package/onboarding/redaction.mjs +22 -3
- package/package.json +6 -1
- package/scripts/publish-check/README.md +22 -1
- package/scripts/publish-check/c2pa.py +121 -0
- package/scripts/publish-check/content_identities.py +3 -1
- package/scripts/publish-check/controller_metadata.py +31 -0
- package/scripts/publish-check/history_scan.py +13 -3
- package/scripts/publish-check/media.py +283 -0
- package/scripts/publish-check/png_text.py +34 -0
- package/scripts/publish-check/public_hygiene.py +39 -4
package/cli/deploy.mjs
CHANGED
|
@@ -6,8 +6,9 @@ import { missing, invalid, refuse, RerError } from './errors.mjs';
|
|
|
6
6
|
import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
|
|
7
7
|
import { packageBuild } from '../modules/deploy-kit/prepare.mjs';
|
|
8
8
|
import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
|
|
9
|
-
import { deployEnvironment } from './deploy-inputs.mjs';
|
|
9
|
+
import { deployEnvironment, deployAgain } from './deploy-inputs.mjs';
|
|
10
10
|
import { claimHostedOwner } from './deploy-owner.mjs';
|
|
11
|
+
import { mutationReadinessFailure } from './deploy-readiness.mjs';
|
|
11
12
|
|
|
12
13
|
export function ownerDeploy(declarationPath, lifecycle) {
|
|
13
14
|
// rer verify serves the build with this declaration's vars and secrets.
|
|
@@ -18,12 +19,14 @@ export function ownerDeploy(declarationPath, lifecycle) {
|
|
|
18
19
|
const local = localOnlyBinding(declaration, localOnlyKeys);
|
|
19
20
|
if (local) throw refuse(`${declarationPath} names the local-only key ${local} as a deploy binding or secret. Remove it; local dev keys never reach a deployment.`);
|
|
20
21
|
const environment = options.environment ?? 'production';
|
|
21
|
-
const again =
|
|
22
|
+
const again = deployAgain(options);
|
|
22
23
|
const change = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
|
|
23
24
|
// The command that finishes what this run started: the same verb, never a plain deploy in its place.
|
|
24
25
|
const retry = options.remove ? `${again} --remove --yes` : options.setSecret ? `${again} --set-secret ${options.setSecret}` : options.unsetSecret ? `${again} --unset-secret ${options.unsetSecret}` : again;
|
|
25
26
|
if (change.length > 1 || change.length && options.dryRun) throw invalid(`Use ${change.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
|
|
26
27
|
if (options.remove && !options.yes) throw invalid(`${again} --remove deletes this environment's Worker and every D1, R2, KV and queue rer created for it, with their data. Add --yes to confirm.`);
|
|
28
|
+
// A dry run reads the settings file too, so a refused key fails here and not only on the real deploy.
|
|
29
|
+
if (options.dryRun && options.envFile) await deployEnvironment({ root, options, declaration, localOnlyKeys });
|
|
27
30
|
if (!change.length) {
|
|
28
31
|
await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
|
|
29
32
|
await checkBuild();
|
|
@@ -37,9 +40,9 @@ export function ownerDeploy(declarationPath, lifecycle) {
|
|
|
37
40
|
store.report('deploy: dry run complete; no deployment occurred and no provider request was made.');
|
|
38
41
|
return { dryRun: true, deployed: false };
|
|
39
42
|
}
|
|
40
|
-
const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
|
|
41
|
-
if (names.some(name => !process.env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
|
|
42
43
|
const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: lifecycle.settings, localOnlyKeys });
|
|
44
|
+
const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
|
|
45
|
+
if (names.some(name => !env[name]?.trim())) throw missing('CLOUDFLARE_ACCOUNT_ID and CLOUDFLARE_API_TOKEN are required. See the deploy-kit owner-account instructions.');
|
|
43
46
|
const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
|
|
44
47
|
const { createRedaction, credentialValues } = await shared('redaction.mjs');
|
|
45
48
|
const redaction = createRedaction();
|
|
@@ -61,35 +64,40 @@ export function ownerDeploy(declarationPath, lifecycle) {
|
|
|
61
64
|
step = 'secrets';
|
|
62
65
|
const binding = options.setSecret ?? options.unsetSecret;
|
|
63
66
|
await owner('secret', [options.setSecret ? '--set' : '--unset', binding]);
|
|
64
|
-
store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker
|
|
67
|
+
store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker, recorded its new version and verified hosted readiness.`);
|
|
65
68
|
return { binding, present: Boolean(options.setSecret), deployed: false };
|
|
66
69
|
}
|
|
70
|
+
store.report(`Deploying the ${environment} environment to your Cloudflare account.`);
|
|
67
71
|
deployed = await owner('deploy', [], env, { notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
|
|
68
72
|
const sent = supplied.filter(name => deployed.sentSecrets.includes(name));
|
|
69
73
|
if (sent.length) store.report(`rer supplied ${sent.join(', ')} from the local setup owner and fresh generated values.`);
|
|
70
74
|
step = 'health';
|
|
71
75
|
await owner('health');
|
|
76
|
+
store.report(`Your application is live on your Cloudflare account at ${deployed.origin}.`);
|
|
72
77
|
step = 'claim owner';
|
|
78
|
+
const { liveSecrets, retiredSecrets, ownerClaim } = await owner('inspect');
|
|
73
79
|
const linked = token => linkDependencies?.(deployed.origin, token);
|
|
74
|
-
const claimed = await claimHostedOwner({ hook: lifecycle.hooks?.owner, setupManifest, declaration, deployed, env, supplied, addSecret: value => redaction.addSecrets([value]),
|
|
75
|
-
secret: (extra, values) => owner('secret', extra, values), beforeClaim: linked });
|
|
80
|
+
const claimed = await claimHostedOwner({ hook: lifecycle.hooks?.owner, setupManifest, declaration, deployed: { ...deployed, liveSecrets, retiredSecrets, ownerClaim }, env, supplied, addSecret: value => { redaction.addSecrets([value]); store.addSecrets?.([value]); },
|
|
81
|
+
secret: (extra, values) => owner('secret', extra, values), beforeClaim: linked, recordClaim: outcome => owner('record-owner', ['--outcome', outcome]), inspect: () => owner('inspect'), store, signal });
|
|
76
82
|
// A seed that claims its owner its own way links with the token it was given.
|
|
77
83
|
if (claimed === undefined) await linked(env[declared(claim?.tokenField ?? 'SETUP_TOKEN')?.environment]);
|
|
78
84
|
// The app claims the OWNER_EMAIL the Worker holds: the local one only when this run uploaded it.
|
|
79
85
|
const email = declared(claim?.emailField);
|
|
80
86
|
const named = !email ? '' : deployed.sentSecrets.includes(email.binding) && env[email.environment] ? ` (${env[email.environment]})` : ` (the ${email.binding} already set on the Worker)`;
|
|
81
|
-
if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${named}; the one-time setup token was
|
|
87
|
+
if (claimed) store.report(`${claimed === 'claimed' ? 'Claimed the hosted owner' : 'The hosted owner was already claimed and was kept'}${named}; the one-time setup token was removed. Sign in by email at ${deployed.origin}.`);
|
|
82
88
|
return { deployed: true, origin: deployed.origin, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
|
|
83
89
|
} catch (error) {
|
|
84
90
|
if (error instanceof RerError) throw error;
|
|
85
91
|
const { commandFailure } = await shared('failures.mjs');
|
|
86
92
|
throw commandFailure(failureText({ error, step, created, again, retry, secretChange: Boolean(change.length && !options.remove), deployed, redact: redaction.redact }));
|
|
87
93
|
}
|
|
88
|
-
}, { declaration: declarationPath });
|
|
94
|
+
}, { declaration: declarationPath, managesHostedOwner: true });
|
|
89
95
|
}
|
|
90
96
|
|
|
91
97
|
// The failing step, its redacted cause, what this run created and the command that continues.
|
|
92
98
|
function failureText({ error, step, created, again, retry, secretChange, deployed, redact }) {
|
|
99
|
+
const readiness = mutationReadinessFailure(error, { again, redact });
|
|
100
|
+
if (readiness) return readiness;
|
|
93
101
|
const diagnostic = deployDiagnostic(error, redact);
|
|
94
102
|
const code = diagnostic?.code ?? (/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redact(error.message) : 'deploy-failed');
|
|
95
103
|
const at = step === 'deploy' ? diagnostic?.step ?? 'prepare deploy' : step;
|
|
@@ -97,8 +105,10 @@ function failureText({ error, step, created, again, retry, secretChange, deploye
|
|
|
97
105
|
if (input) return `Deploy needs ${input}, which the deploy declaration lists as a secret and the Worker does not hold it. Set ${input} in the environment or in an --env-file file (an owned 0600 file of KEY=VALUE lines), then run the same command again. Nothing was uploaded.${created.length ? ` Resources created this attempt: ${created.join(', ')}.` : ''}`;
|
|
98
106
|
if (code === 'remove-incomplete') return `Removal stopped with ${error.remaining.join(', ')} still present${error.failed.length ? ` (${error.failed.map(item => `${item.resource}: ${item.code}`).join('; ')})` : ''}. Removed: ${error.removed.join(', ') || 'nothing'}. An R2 bucket must be empty before it can be deleted. Run ${again} --remove --yes again to finish.`;
|
|
99
107
|
if (code === 'nothing-to-remove') return `This checkout has no rer deployment record for this environment, so --remove deletes nothing. Run it from the checkout that deployed it.`;
|
|
100
|
-
if (code === 'owner-
|
|
108
|
+
if (code === 'owner-token-retirement-failed') return `The application is deployed at ${deployed?.origin}; owner ${error.owner}; setup token retirement failed (${redact(String(error.cause))}). Run ${again} --unset-secret ${error.tokenKey ?? 'SETUP_TOKEN'} using the same environment credentials, or run ${again} again to finish.`;
|
|
109
|
+
if (code === 'owner-claim-failed') return `The application is deployed at ${deployed?.origin}, but claiming its owner failed: ${redact(String(error.cause))}. Owner claim and setup token retirement are not confirmed; run ${again} again using the same environment credentials to finish.`;
|
|
101
110
|
const resource = diagnostic?.resource ?? (error.resource ? redact(String(error.resource)) : undefined);
|
|
111
|
+
if (code === 'resource-not-listed') return `Deploy stopped at ${at}: ${resource} was created, but Cloudflare's account listing still does not show it after two minutes. Nothing was uploaded.\nResources created this attempt: ${created.join(', ') || 'none'}.\nRun ${again} again once the listing catches up: it reuses ${resource}. To delete what this environment created instead: ${again} --remove --yes.`;
|
|
102
112
|
const lines = [`Deploy failed at ${at}: ${code}${resource ? ` (${resource})` : ''}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.`];
|
|
103
113
|
if (diagnostic?.output && diagnostic.output !== '(no provider output)') lines.push(`First redacted output lines:\n${diagnostic.output}`);
|
|
104
114
|
lines.push(`Resources created this attempt: ${created.join(', ') || 'none'}.`);
|
package/cli/detached-server.mjs
CHANGED
|
@@ -35,7 +35,7 @@ export async function detachedServer(options, argv, out) {
|
|
|
35
35
|
if (event.event==='ready') {
|
|
36
36
|
keep=true;child.unref();
|
|
37
37
|
const dev = event.openUrl ? {openUrl:event.openUrl,identity:options.verb === 'dev' && !options.login,signedInAs:event.signedInAs,ownerLeft:options.verb === 'dev' && !options.login && event.signedInAs === null} : undefined;
|
|
38
|
-
ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl);
|
|
38
|
+
ready(out,event.url,dev,options.json,Boolean(event.signInUrl),event.siteUrl,event.demo);
|
|
39
39
|
return 0;
|
|
40
40
|
}
|
|
41
41
|
if (event.event==='waiting' || event.event==='warning') out.event(event.event,event);
|
package/cli/export.mjs
CHANGED
|
@@ -6,7 +6,7 @@ import { cliVersion } from './version.mjs';
|
|
|
6
6
|
|
|
7
7
|
// Copy whole maintained source/tooling directories. Python publication scans
|
|
8
8
|
// load sibling modules and data dynamically; they belong in the inventory too.
|
|
9
|
-
export const sourceRoots = ['bin/rer.mjs', 'cli', 'modules/job-queue', 'modules/release', 'modules/deploy-kit', 'modules/container', 'modules/integrations/core', 'modules/mail', 'modules/ai', 'modules/portability', 'modules/erasure', 'modules/dependencies', 'modules/operator-auth', 'modules/owner-claim', 'operations', 'scripts/publish-check', 'scripts/publish-check.mjs', 'composition/git-source.mjs', 'edition-authoring/manifest.mjs', 'edition-authoring/files.mjs'];
|
|
9
|
+
export const sourceRoots = ['bin/rer.mjs', 'cli', 'modules/job-queue', 'modules/release', 'modules/deploy-kit', 'modules/container', 'modules/demo-host', 'modules/integrations/core', 'modules/mail', 'modules/ai', 'modules/portability', 'modules/erasure', 'modules/dependencies', 'modules/operator-auth', 'modules/owner-claim', 'operations', 'scripts/publish-check', 'scripts/publish-check.mjs', 'composition/git-source.mjs', 'edition-authoring/manifest.mjs', 'edition-authoring/files.mjs'];
|
|
10
10
|
export async function inventory(base, roots = sourceRoots) {
|
|
11
11
|
const files = [];
|
|
12
12
|
async function walk(path) {
|
package/cli/hosted-output.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { pathToFileURL } from 'node:url';
|
|
|
6
6
|
import { RUN_WORKER_FIRST } from '../modules/deploy-kit/render.mjs';
|
|
7
7
|
import { publicAssetPaths } from '../modules/deploy-kit/artifact.mjs';
|
|
8
8
|
import { migrate } from '../modules/container/migrations.mjs';
|
|
9
|
+
import { readMigrationRewrites, withRewrites } from '../modules/deploy-kit/migrations.mjs';
|
|
9
10
|
import { RerError } from './errors.mjs';
|
|
10
11
|
|
|
11
12
|
// Serve the production build the way rer deploy hosts it (assets behind the
|
|
@@ -76,7 +77,7 @@ export async function serveHosted(root, { declaration: declarationPath = 'deploy
|
|
|
76
77
|
const sql = await readFile(join(directory, name), 'utf8');
|
|
77
78
|
plan.push({ name, sql, sha256: createHash('sha256').update(sql).digest('hex'), statements: split(sql) });
|
|
78
79
|
}
|
|
79
|
-
await migrate(await mf.getD1Database(item.binding, 'app'), plan);
|
|
80
|
+
await migrate(await mf.getD1Database(item.binding, 'app'), withRewrites(plan, await readMigrationRewrites(root), { partial: true }));
|
|
80
81
|
}
|
|
81
82
|
} catch (error) { await mf.dispose(); throw error; }
|
|
82
83
|
return { client, identity, origin: ORIGIN, close: () => mf.dispose(),
|
package/cli/lifecycle.mjs
CHANGED
|
@@ -17,6 +17,16 @@ import { ensureDevKey, readDevKey, refuseDevLayerOutput, openUrl, DEV_KEY_HEADER
|
|
|
17
17
|
import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs';
|
|
18
18
|
|
|
19
19
|
export async function runCommand(project, options, out) {
|
|
20
|
+
if (options.verb === 'deploy' && !options.site && !options.demo) {
|
|
21
|
+
const { validateDeployOptions } = await import('./deploy-command.mjs');
|
|
22
|
+
validateDeployOptions(options);
|
|
23
|
+
const { withDeployEnvironment } = await import('./deploy-inputs.mjs');
|
|
24
|
+
return withDeployEnvironment(project.root, options, redaction => runLifecycle(project, options, out, redaction));
|
|
25
|
+
}
|
|
26
|
+
return runLifecycle(project, options, out);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
async function runLifecycle(project, options, out, deployRedaction) {
|
|
20
30
|
if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
|
|
21
31
|
const cancellation = new AbortController(), signal = cancellation.signal;
|
|
22
32
|
let termination, operation, store, temporary, proxy, failed = false;
|
|
@@ -59,6 +69,7 @@ export async function runCommand(project, options, out) {
|
|
|
59
69
|
const state = { logs: [] };
|
|
60
70
|
store = await createCommands(directory, state, { terminal: text => out.progress(text), onWaiting: options.json ? details => out.event('waiting', details) : undefined, queue: ['install','setup','build','verify','deploy','export','import'].includes(options.verb)?{verb:options.verb,originalCI:process.env.CI==='true'}:undefined });
|
|
61
71
|
store.state = state;
|
|
72
|
+
if (deployRedaction) store.addSecrets?.(Object.values(process.env).filter(value => value && deployRedaction.redact(value) !== value));
|
|
62
73
|
store.setupManifest = project.setupManifest;
|
|
63
74
|
store.step = async label => { state.step = store.redact(label); store.report(label); };
|
|
64
75
|
store.phase = async phase => { state.phase = phase; };
|
|
@@ -69,8 +80,11 @@ export async function runCommand(project, options, out) {
|
|
|
69
80
|
const adapter = project.adapter;
|
|
70
81
|
// The run site: static pages built from edition identity, never the app build.
|
|
71
82
|
if (options.site) return await (await import('./site.mjs')).siteCommand(project, options, store, signal, out);
|
|
83
|
+
// The hosted demo: a front Worker and the seed's container image on demo.<domain>.
|
|
84
|
+
if (options.verb === 'deploy' && options.demo) return await (await import('./demo-host.mjs')).demoHostCommand(project, options, store, signal, out, operation);
|
|
72
85
|
if (options.verb === 'install') {
|
|
73
|
-
|
|
86
|
+
const { dependencyEnvironment } = await shared('dependency-environment.mjs');
|
|
87
|
+
await store.run('Installing frozen dependencies', 'pnpm', ['install', '--frozen-lockfile'], { cwd: project.root, signal, env: { ...dependencyEnvironment(), CI: 'true' } }); return {};
|
|
74
88
|
}
|
|
75
89
|
if (options.verb === 'verify') {
|
|
76
90
|
const target = adapter.verify;
|
|
@@ -86,10 +100,7 @@ export async function runCommand(project, options, out) {
|
|
|
86
100
|
if (options.verb === 'deploy') {
|
|
87
101
|
if (typeof adapter.deploy !== 'function') throw refuse('Owner-account deploy declaration is missing.');
|
|
88
102
|
const link = async (origin, appSetupToken) => linkDependencies({project,input:project.setupSeed?await savedSetupAnswers(project,project.setupSeed.model):undefined,origin,store,signal,appSetupToken});
|
|
89
|
-
|
|
90
|
-
// Base's owner-account deploy links while its setup token is live; another deploy hook is linked here.
|
|
91
|
-
if(deployed.deployed && !options.dryRun && !deployed.dependenciesLinked) await link(deployed.origin,process.env[project.setupManifest?.steps.find(step=>step.type==='owner-claim')?.tokenField??'SETUP_TOKEN']);
|
|
92
|
-
return deployed;
|
|
103
|
+
return await (await import('./deploy-command.mjs')).deployCommand({ adapter, root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity), setupManifest: project.setupManifest, linkDependencies: link });
|
|
93
104
|
}
|
|
94
105
|
// Setup writes the checkout's dev key; its temporary app and the CLI's own
|
|
95
106
|
// outbox reads present it. Captured logs redact it.
|
|
@@ -114,8 +125,10 @@ export async function runCommand(project, options, out) {
|
|
|
114
125
|
if (['setup', 'build'].includes(options.verb)) await refuseDevLayerOutput(project.root, adapter.devIdentity);
|
|
115
126
|
const url = selected?.url, signInUrl = url ? url + '/__dev/outbox' : undefined;
|
|
116
127
|
if (options.verb === 'setup') {
|
|
117
|
-
|
|
118
|
-
|
|
128
|
+
// Only a connected provider leaves first setup open; a native claim with
|
|
129
|
+
// every optional dependency unanswered has already closed it.
|
|
130
|
+
const {connected}=await linkDependencies({project,input,origin:url,store,signal});
|
|
131
|
+
if(connected.length) {
|
|
119
132
|
const proof=await localCliSessionProof(project);
|
|
120
133
|
store.addSecrets?.([proof]);
|
|
121
134
|
const completed=await appFetch(url+'/auth/setup-complete',{method:'POST',headers:{Origin:url,'Content-Type':'application/json','x-rer-local-cli':proof},body:'{}',signal});
|
package/cli/local.mjs
CHANGED
|
@@ -73,6 +73,13 @@ export async function reset(project, options, out) {
|
|
|
73
73
|
await refuseLegacyProcess(project,{reset:true});
|
|
74
74
|
const { strandedOriginals } = await shared('local/recovery.mjs');
|
|
75
75
|
if ((await strandedOriginals(project.root)).length) throw refuse('An interrupted build holds private settings. Run rer build to recover them before reviewing reset.');
|
|
76
|
+
// The seed's own safety check (legacy data it still has to recover, for
|
|
77
|
+
// example) runs before the inventory and confirmation; --yes cannot skip it.
|
|
78
|
+
const preflight = project.adapter.resetPreflight;
|
|
79
|
+
if (preflight !== undefined) {
|
|
80
|
+
if (typeof preflight !== 'function') throw refuse('The seed adapter declares a reset preflight that is not a function.');
|
|
81
|
+
try { await preflight({ root: project.root }); } catch (error) { throw refuse(`Reset refused by the seed's preflight: ${error?.message || 'no reason given'}`); }
|
|
82
|
+
}
|
|
76
83
|
const declared = project.adapter.settings ?? [];
|
|
77
84
|
for (const path of declared) if (!/^\.dev\.vars(?:\.previous)?$|^data\/[a-zA-Z0-9_-]+\.json$/.test(path)) throw refuse('Generated settings inventory is unsafe; declare specific private settings files.');
|
|
78
85
|
const paths = [...new Set(['.wrangler/state', '.rer/local', '.seed/installer', ...declared])];
|
package/cli/main.mjs
CHANGED
|
@@ -27,7 +27,7 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
27
27
|
if (project.profile === 'independent' && !['describe', 'check', 'doctor'].includes(options.verb)) options = parse(argv, process.cwd(), { independent: true });
|
|
28
28
|
else if (options.forwarded.length) throw invalid('First-party commands accept only the documented flags, without forwarded arguments.');
|
|
29
29
|
out = output(options);
|
|
30
|
-
if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return (await import('./detached-server.mjs')).detachedServer(options, argv, out);
|
|
30
|
+
if (project.profile === 'first-party' && options.detach && ['dev','preview'].includes(options.verb)) return await (await import('./detached-server.mjs')).detachedServer(options, argv, out);
|
|
31
31
|
if (options.verb === 'describe') { const result = await describe(project); out.human(options.json ? JSON.stringify(result) : JSON.stringify(result, null, 2)); return 0; }
|
|
32
32
|
if (options.verb === 'check') {
|
|
33
33
|
const result = await check(project, options.profile);
|
package/cli/managed-server.mjs
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { prepareLiveModels } from './ai-local.mjs';
|
|
2
2
|
import {fileURLToPath} from 'node:url';
|
|
3
3
|
import {join} from 'node:path';
|
|
4
|
+
import {rm} from 'node:fs/promises';
|
|
4
5
|
import { serverRegistry } from './server-registry.mjs';
|
|
5
6
|
import { activityClock } from './server-activity.mjs';
|
|
6
7
|
import { runtimeFrontDoor } from './runtime-front-door.mjs';
|
|
@@ -13,6 +14,7 @@ import { tools } from './inspect.mjs';
|
|
|
13
14
|
import { invalid,missing,refuse,RerError,lifecycleError } from './errors.mjs';
|
|
14
15
|
import { processInfo } from '../modules/job-queue/processes.mjs';
|
|
15
16
|
import { declaredCrons,runScheduled } from './scheduled.mjs';
|
|
17
|
+
import { demoAdopted,demoGuarded,demoState } from './demo.mjs';
|
|
16
18
|
import { readDevKey,requireDevKey,refuseManagedInstallation,resolvePersona,openUrl,DEV_KEY_HEADER } from './dev-identity.mjs';
|
|
17
19
|
import { announceDoorRoute,claimSavedName,moveCheckout,routeRunning,verifyDoorFor } from './door-mode.mjs';
|
|
18
20
|
|
|
@@ -22,9 +24,10 @@ import { announceDoorRoute,claimSavedName,moveCheckout,routeRunning,verifyDoorFo
|
|
|
22
24
|
// serves no outbox (a seed's preview without one) is not pointed at it.
|
|
23
25
|
// A seed that declares a run site also gets RER_SITE: its static run site
|
|
24
26
|
// (homepage, /run, docs) served locally on its own port, after the ready lines.
|
|
25
|
-
|
|
27
|
+
// A demo adds RER_DEMO_ENDS_ON_STOP: its own data folder, deleted when it stops.
|
|
28
|
+
export function ready(out,url,dev,json=false,outbox=true,siteUrl,demo){
|
|
26
29
|
const signInUrl=url+'/__dev/outbox',open=dev&&(dev.identity||outbox);
|
|
27
|
-
if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{}),...(siteUrl?{siteUrl}:{})});return;}
|
|
30
|
+
if(json){out.event('ready',{url,...(outbox?{signInUrl}:{}),...(dev?{signedInAs:dev.signedInAs??null,...(open?{openUrl:dev.openUrl}:{})}:{}),...(siteUrl?{siteUrl}:{}),...(demo?{demo}:{})});return;}
|
|
28
31
|
out.human(`RER_READY ${url}`);
|
|
29
32
|
if(dev?.identity){if(dev.signedInAs)out.human(`RER_SIGNED_IN ${dev.signedInAs}`);else if(dev.ownerLeft)out.human('No active owner remains in this app, so rer dev cannot sign in as one. Use rer dev --login with another operator, or rer reset --yes and rer setup for a new owner.');out.human(`RER_OPEN ${dev.openUrl}`);out.human('Open that link on this computer. Its fragment holds this checkout\'s dev key, which the page keeps only as a cookie; never share the link.');}
|
|
30
33
|
else if(outbox){
|
|
@@ -33,10 +36,13 @@ export function ready(out,url,dev,json=false,outbox=true,siteUrl){
|
|
|
33
36
|
out.human(`Open ${dev?'the last link on this computer (it carries the dev key; never share it)':'the outbox'}, choose the newest sign-in message and follow its link. No email was sent.`);
|
|
34
37
|
}
|
|
35
38
|
if(siteUrl)out.human(`RER_SITE ${siteUrl}`);
|
|
39
|
+
if(demo){out.human(`RER_DEMO_ENDS_ON_STOP ${demo.state}`);out.human(`This demo runs on its own data in that folder, which is deleted when it stops: rer demo --stop, Ctrl-C, or at its end (${demo.ends.slice(11,16)}). Nothing it does is sent; this checkout's own data is untouched.`);}
|
|
36
40
|
}
|
|
37
41
|
export async function managedServer(project,options,out,{clock={}}={}){
|
|
38
42
|
const registry=await serverRegistry(project.root),controller=new AbortController();
|
|
39
|
-
|
|
43
|
+
// A demo registers as its own mode: `rer dev --stop` never stops it by accident.
|
|
44
|
+
const mode=options.demo?'demo':options.verb;
|
|
45
|
+
let store,operation,proxy,activity,app,termination,reason,cleanupPromise,finished,selected,cleaned,site,demo,demoEnd;
|
|
40
46
|
const scheduling=new AbortController();
|
|
41
47
|
let activityWrites=Promise.resolve(),pendingActivity,activityWriting=false,infrastructureFailure;
|
|
42
48
|
function recordActivity(fields){
|
|
@@ -45,27 +51,27 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
45
51
|
}
|
|
46
52
|
const stopped=new Promise(resolve=>{finished=resolve;});
|
|
47
53
|
const cleanupDone=new Promise(resolve=>{cleaned=resolve;});
|
|
48
|
-
const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();activity?.close();await site?.close();await store?.closeCommands();await proxy?.close();await operation?.release();})();
|
|
54
|
+
const cleanup=()=>cleanupPromise??=(async()=>{scheduling.abort();clearTimeout(demoEnd);activity?.close();await site?.close();await store?.closeCommands();if(demo)await rm(demo.state,{recursive:true,force:true});await proxy?.close();await operation?.release();})();
|
|
49
55
|
const stop=async why=>{reason??=why;controller.abort();finished();await cleanupDone;return {};};
|
|
50
56
|
const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>{termination=name;void stop(name);} ]));
|
|
51
57
|
for(const [name,handler]of handlers)process.on(name,handler);
|
|
52
58
|
try{
|
|
53
|
-
if(options.stop){await registry.stop(
|
|
59
|
+
if(options.stop){await registry.stop(mode,options);return {};}
|
|
54
60
|
controller.signal.throwIfAborted();
|
|
55
61
|
if(options.door||options.direct)await moveCheckout(project,options,out,registry);
|
|
56
62
|
const devIdentity=project.adapter.devIdentity;
|
|
57
63
|
if(options.as!==undefined&&!(devIdentity&&options.verb==='dev'))throw invalid('--as needs a seed that declares devIdentity.');
|
|
58
64
|
let devKey=devIdentity?await readDevKey(project.root):null;
|
|
59
|
-
let current=await registry.existing(
|
|
65
|
+
let current=await registry.existing(mode,options);
|
|
60
66
|
if(!current){
|
|
61
|
-
const reservation=await registry.reserve(
|
|
67
|
+
const reservation=await registry.reserve(mode,options);
|
|
62
68
|
current=reservation.existing;
|
|
63
69
|
}
|
|
64
70
|
if(current){
|
|
65
71
|
// Resolve a competing reservation through the same explicit-option check.
|
|
66
|
-
current=await registry.existing(
|
|
72
|
+
current=await registry.existing(mode,options);
|
|
67
73
|
const running=await registry.waitReady(current,controller.signal);
|
|
68
|
-
await registry.existing(
|
|
74
|
+
await registry.existing(mode,options);
|
|
69
75
|
if(options.verb==='dev' && (running.models??'fake')!==(options.models??'fake'))throw refuse('Stop the running dev server before changing --models mode.');
|
|
70
76
|
if(options.modelsService)throw refuse('Stop the running dev server before saving live-dev credentials.');
|
|
71
77
|
if(options.verb==='dev'&&Boolean(running.login)!==Boolean(options.login))throw refuse(`The running dev server ${running.login?'uses':'does not use'} --login. Stop it with rer dev --stop to change sign-in mode.`);
|
|
@@ -79,10 +85,10 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
79
85
|
// A key is sent (or printed) for a door origin only once the door proves it is ours.
|
|
80
86
|
if(devIdentity)await verifyDoorFor(running.url);
|
|
81
87
|
const dev=devIdentity&&await devReady(running.url,devKey,options,controller.signal);
|
|
82
|
-
await emitReady(out,options,project.root,running.url,dev,true,running.siteUrl);return {reused:true};
|
|
88
|
+
await emitReady(out,options,project.root,running.url,dev,true,running.siteUrl,running.demo);return {reused:true};
|
|
83
89
|
}
|
|
84
90
|
await registry.serve(async action=>{
|
|
85
|
-
if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe,siteUrl:site?.url};
|
|
91
|
+
if(action==='status')return {...activity?.snapshot(),url:selected?.url,probe:proxy?.probe,siteUrl:site?.url,demo};
|
|
86
92
|
if(action==='stop')return stop('explicit stop');
|
|
87
93
|
throw Error('Unknown management operation');
|
|
88
94
|
});
|
|
@@ -99,10 +105,11 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
99
105
|
if(!selected.vars[keys.email])throw missing('Private owner settings are missing. Run rer setup first.');
|
|
100
106
|
if(devIdentity&&!devKey)devKey=await requireDevKey(project.root);
|
|
101
107
|
if(devIdentity&&options.verb==='dev'&&!options.login)refuseManagedInstallation(selected.vars,process.env);
|
|
102
|
-
if(project.adapter.localPrerequisites&&!await project.adapter.localPrerequisites({root:project.root,vars:selected.vars,email:selected.vars[keys.email]}))throw missing('Local migrations, owner or local authentication/mail settings are missing. Run rer setup first.');
|
|
108
|
+
if(project.adapter.localPrerequisites&&!await project.adapter.localPrerequisites({root:project.root,vars:selected.vars,email:selected.vars[keys.email],demo:Boolean(options.demo)}))throw missing('Local migrations, owner or local authentication/mail settings are missing. Run rer setup first.');
|
|
103
109
|
await registry.update({url:selected.url,host:selected.host,port:selected.port,login:Boolean(options.login),models:options.models??'fake'});
|
|
104
110
|
const {privateDirectory}=await shared('private-files.mjs');
|
|
105
|
-
await
|
|
111
|
+
if(options.demo){await demoAdopted(project.root);demo=await demoState(project.root,options.idleTimeout);}
|
|
112
|
+
else await privateDirectory(project.root,['.wrangler','state']);
|
|
106
113
|
const directory=await privateDirectory(project.root,['.seed','installer',...(process.env.RER_ONBOARDING_PROCESS_OWNER?[`commands-${process.pid}`]:[])]);
|
|
107
114
|
const {createCommands}=await shared('commands.mjs');const state={logs:[]};
|
|
108
115
|
store=await createCommands(directory,state,{terminal:text=>out.progress(text),onWaiting:options.json?details=>out.event('waiting',details):undefined,custodyToken:registry.custodyToken(),queue:{verb:options.verb,originalCI:process.env.CI==='true'}});
|
|
@@ -110,11 +117,12 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
110
117
|
store.setupManifest=seed?.manifest;if(devKey)store.addSecrets([devKey]);
|
|
111
118
|
await tools(project.root,store,controller.signal);
|
|
112
119
|
activity=activityClock({...clock,seconds:options.idleTimeout,onIdle:()=>void stop('idle timeout'),onChange:recordActivity});
|
|
120
|
+
if(demo)demoEnd=setTimeout(()=>void stop('demo ended'),Math.min(Date.parse(demo.ends)-Date.now(),2**31-1));
|
|
113
121
|
const crons=project.adapter.scheduled!==false?await declaredCrons(project.root,project.adapter).catch(error=>{throw refuse(`The declared scheduled triggers cannot run locally: ${error.message}`);}):[];
|
|
114
122
|
proxy=await runtimeFrontDoor({host:selected.host,port:selected.port,activity,door:selected.door,devKey:devKey??undefined,origin:selected.url});const internal=proxy.runtimePort;
|
|
115
123
|
if(selected.door)await announceDoorRoute(selected,proxy,registry);
|
|
116
124
|
const modelsLive = options.verb==='dev' ? await prepareLiveModels(project.root,options,store,controller.signal) : undefined;
|
|
117
|
-
const adapterOptions={modelsLive,selectedOrigin:selected.url,operationToken:operation.token,privateSettings:project.adapter.settings,runtimePort:internal,devKey:devKey??undefined,devLogin:Boolean(options.login),scheduled:crons.length>0};
|
|
125
|
+
const adapterOptions={modelsLive,demo,selectedOrigin:selected.url,operationToken:operation.token,privateSettings:project.adapter.settings,runtimePort:internal,devKey:devKey??undefined,devLogin:Boolean(options.login),scheduled:crons.length>0};
|
|
118
126
|
const input={[keys.email]:selected.vars[keys.email]};
|
|
119
127
|
if(options.verb==='preview')await productionBuild(project,()=>store.run('Building fixed production output',process.execPath,[fileURLToPath(new URL('../bin/rer.mjs',import.meta.url)),'build','--cwd',project.root,'--json'],{cwd:project.root,phase:'build',signal:controller.signal,env:{...process.env,RER_LOCAL_OPERATION:operation.token},afterCommand:()=>recoverInterrupted(project.root,operation)}),text=>store.report(text));
|
|
120
128
|
await serverResources(project.root);
|
|
@@ -127,6 +135,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
127
135
|
},controller.signal,{...adapterOptions,verb:options.verb,dev:project.adapter.dev,preview:project.adapter.preview});
|
|
128
136
|
// A key is sent (or printed) for a door origin only once the door proves it is ours.
|
|
129
137
|
if(devKey)await verifyDoorFor(selected.url);
|
|
138
|
+
if(demo)await demoGuarded(selected.url,demo.state,controller.signal);
|
|
130
139
|
const dev=devIdentity&&await devReady(selected.url,devKey,options,controller.signal);
|
|
131
140
|
const outbox=await outboxServed(selected.url,devKey,controller.signal);
|
|
132
141
|
if(options.verb==='dev'&&project.adapter.site){
|
|
@@ -140,7 +149,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
140
149
|
if(options.json)out.event('warning',{code:error.code,message});else out.progress(`Warning: ${message}\n`);
|
|
141
150
|
}
|
|
142
151
|
}
|
|
143
|
-
activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url);
|
|
152
|
+
activity.ready();await emitReady(out,options,project.root,selected.url,dev,outbox,site?.url,demo);
|
|
144
153
|
if(crons.length){
|
|
145
154
|
out.progress(`Running ${crons.length===1?'the scheduled trigger':'the scheduled triggers'} locally: ${crons.join(' | ')}\n`);
|
|
146
155
|
void runScheduled({crons,url:`http://127.0.0.1:${internal}`,path:options.verb==='preview'?'/cdn-cgi/local/scheduled':undefined,signal:scheduling.signal,report:run=>reportScheduled(out,options,run)});
|
|
@@ -150,7 +159,7 @@ export async function managedServer(project,options,out,{clock={}}={}){
|
|
|
150
159
|
if(infrastructureFailure)throw new RerError('server_bookkeeping_failed','Server bookkeeping failed; owned serving was stopped.',1);
|
|
151
160
|
if(result?.code)throw new RerError('app_exited','The application stopped unexpectedly.',result.code);
|
|
152
161
|
out.progress(`Server stopped: ${reason}.\n`);return {};
|
|
153
|
-
}catch(error){if(termination)throw new RerError('cancelled',`Stopped by ${termination} after owned children were stopped.`,termination==='SIGINT'?130:143);if(['idle timeout','explicit stop'].includes(reason))return {};throw await lifecycleError(error,store);}finally{await cleanup();cleaned();while(activityWriting)await activityWrites;await registry.close();for(const [name,handler]of handlers)process.off(name,handler);}
|
|
162
|
+
}catch(error){if(termination)throw new RerError('cancelled',`Stopped by ${termination} after owned children were stopped.`,termination==='SIGINT'?130:143);if(['idle timeout','explicit stop','demo ended'].includes(reason))return {};throw await lifecycleError(error,store);}finally{await cleanup();cleaned();while(activityWriting)await activityWrites;await registry.close();for(const [name,handler]of handlers)process.off(name,handler);}
|
|
154
163
|
}
|
|
155
164
|
function reportScheduled(out,options,run){
|
|
156
165
|
const error=options.verb==='preview'&&run.status===404 ? 'The preview target does not expose /cdn-cgi/local/scheduled. Forward the adapter context scheduled flag to previewTarget, or set RER_RUNTIME_TRIGGERS=1 in a custom target using the shared production runtime. For another runtime, expose that internal trigger endpoint or declare scheduled:false.' : run.error;
|
|
@@ -167,12 +176,12 @@ async function outboxServed(url,devKey,signal){
|
|
|
167
176
|
// dev also keeps its RER_OPEN link in owner-only .rer/local/open-url (0600),
|
|
168
177
|
// which reset removes with the rest of .rer/local, for a person or agent that
|
|
169
178
|
// did not keep the terminal output.
|
|
170
|
-
async function emitReady(out,options,root,url,dev,outbox,siteUrl){
|
|
179
|
+
async function emitReady(out,options,root,url,dev,outbox,siteUrl,demo){
|
|
171
180
|
if(options.verb==='dev'&&dev?.openUrl){
|
|
172
181
|
const {privateDirectory,writePrivateFile}=await shared('private-files.mjs');
|
|
173
182
|
await writePrivateFile(join(await privateDirectory(root,['.rer','local']),'open-url'),dev.openUrl+'\n');
|
|
174
183
|
}
|
|
175
|
-
ready(out,url,dev||undefined,options.json,outbox,siteUrl);
|
|
184
|
+
ready(out,url,dev||undefined,options.json,outbox,siteUrl,demo);
|
|
176
185
|
}
|
|
177
186
|
async function devReady(url,key,options,signal){
|
|
178
187
|
if(options.verb==='preview')return {openUrl:openUrl(url,key,{path:'/__dev/key',next:'/__dev/outbox'})};
|
package/cli/parse.mjs
CHANGED
|
@@ -2,7 +2,7 @@ import { resolve } from 'node:path';
|
|
|
2
2
|
import { commonFlags, serverDefaults, verbs } from './contract.mjs';
|
|
3
3
|
import { invalid, refuse } from './errors.mjs';
|
|
4
4
|
import { isLoopbackHost } from '../modules/operator-auth/loopback.mjs';
|
|
5
|
-
const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site', '--remove']);
|
|
5
|
+
const booleans = new Set(['--help', '--json', '--non-interactive', '--yes', '--dry-run', '--open', '--stop', '--detach', '--foreground', '--no-queue', '--login', '--legacy-v1', '--acknowledge-unverified-metadata', '--door', '--direct', '--site', '--remove', '--demo']);
|
|
6
6
|
export const mailbox = value => typeof value === 'string' && value.length <= 254 && /^[a-zA-Z0-9.!+_%\-]+@[a-zA-Z0-9\-]+(?:\.[a-zA-Z0-9\-]+)+$/.test(value);
|
|
7
7
|
// A loopback host: the shared predicate (including `*.localhost` door names),
|
|
8
8
|
// plus `::1` as --host and a bracket-stripped hostname spell it.
|
|
@@ -17,6 +17,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
17
17
|
if (!verb || verb === '--help') return { help: true };
|
|
18
18
|
if (verb === 'release') return { verb, releaseArgs: args };
|
|
19
19
|
if (verb === 'archive') return { verb, archiveArgs: args };
|
|
20
|
+
// rer demo is rer dev --demo.
|
|
21
|
+
if (verb === 'demo' && !independent) return parse(['dev', '--demo', ...args], cwd, { independent, deferTail });
|
|
20
22
|
// Independent projects own verb names; only the common wrapper flags apply
|
|
21
23
|
// to unknown names. Profile resolution rejects undeclared commands later.
|
|
22
24
|
if (!/^[a-z][a-z0-9-]*$/.test(verb)) throw invalid('Unknown rer command. Run rer --help.');
|
|
@@ -53,8 +55,10 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
53
55
|
if(options.models!==undefined && !['fake','live'].includes(options.models))throw invalid('--models takes fake or live.');
|
|
54
56
|
if(options.modelsService!==undefined && options.models!=='live')throw invalid('--models-service requires --models live.');
|
|
55
57
|
if(options.as!==undefined && (options.login || !/^[a-z][a-z0-9-]{0,39}$/.test(options.as)))throw invalid(options.login?'--as and --login cannot be combined.':'--as takes a persona name such as owner, signed-out or admin.');
|
|
58
|
+
if(options.demo&&['--door','--direct','--as','--login','--models','--models-service'].some(flag=>options.supplied.has(flag)))throw invalid('A demo opens signed in as the owner on its own data: --demo takes only --port, --host, --idle-timeout, --stop, --detach and --foreground.');
|
|
56
59
|
if(['dev','preview'].includes(verb) && !options.stop){
|
|
57
|
-
|
|
60
|
+
// A demo ends one hour after it starts unless --idle-timeout says otherwise.
|
|
61
|
+
const value=options.idleTimeout??(options.demo?'3600':process.env.RER_SERVER_IDLE_SECONDS??String(serverDefaults.idleSeconds));
|
|
58
62
|
if(!/^[1-9][0-9]*$/.test(value)||!Number.isSafeInteger(Number(value)))throw invalid('Idle timeout must be positive integer seconds.');
|
|
59
63
|
options.idleTimeout=Number(value);
|
|
60
64
|
}
|
|
@@ -73,6 +77,7 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
73
77
|
if (options.profile !== undefined && options.profile !== 'first-party') throw invalid('The supported explicit profile is first-party.');
|
|
74
78
|
if (options.environment !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(options.environment)) throw invalid('Provide a valid deployment environment name.');
|
|
75
79
|
for (const key of ['setSecret', 'unsetSecret']) if (options[key] !== undefined && !/^[A-Z][A-Z0-9_]{0,63}$/.test(options[key])) throw invalid('Name a declared secret binding, such as SESSION_SECRET.');
|
|
80
|
+
if(verb==='deploy'&&options.demo&&(options.site||['platform','image','bindAddress','httpPort','httpsPort','envFile','host','remove','setSecret','unsetSecret'].some(key=>options[key]!==undefined)))throw invalid('--demo takes only --environment and --dry-run: the hosted demo is its own Worker on demo.<domain>.');
|
|
76
81
|
if ((options.remove || options.setSecret || options.unsetSecret) && options.site) throw invalid('--remove, --set-secret and --unset-secret act on the application Worker, not the run site.');
|
|
77
82
|
if (options.origin !== undefined) {
|
|
78
83
|
let url; try { url = new URL(options.origin); } catch { throw invalid('Provide a valid credential-free origin.'); }
|
package/cli/pnpm-probe.mjs
CHANGED
|
@@ -1,10 +1,13 @@
|
|
|
1
1
|
import { missing } from './errors.mjs';
|
|
2
|
+
import { shared } from './shared.mjs';
|
|
2
3
|
|
|
3
4
|
// A version check owns its descendants just like a lifecycle command. Cleanup
|
|
4
5
|
// starts at leader exit, even when quiet descendants still hold the pipes.
|
|
6
|
+
// The package manager never sees the caller's credentials.
|
|
5
7
|
export async function probePnpm(root, store, signal, env = process.env) {
|
|
8
|
+
const { dependencyEnvironment } = await shared('dependency-environment.mjs');
|
|
6
9
|
const probeSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)].filter(Boolean));
|
|
7
|
-
const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, env, signal: probeSignal });
|
|
10
|
+
const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, env: dependencyEnvironment(env), signal: probeSignal });
|
|
8
11
|
let version = '';
|
|
9
12
|
child.stdout.on('data', chunk => { version = (version + chunk).slice(-1024); });
|
|
10
13
|
const code = await new Promise(resolve => { child.once('error', () => resolve(null)); child.once('exit', code => resolve(code)); });
|
package/cli/portability.mjs
CHANGED
|
@@ -6,6 +6,7 @@ import { readLegacyArchive } from '../modules/portability/format.mjs';
|
|
|
6
6
|
import { fileRecords } from '../modules/portability/files.mjs';
|
|
7
7
|
import { RerError, refuse } from './errors.mjs';
|
|
8
8
|
import { policyModuleURL, serializablePolicy } from './portability-policy.mjs';
|
|
9
|
+
import { shared } from './shared.mjs';
|
|
9
10
|
|
|
10
11
|
// Seeds declare their real bindings and data policy. The shared module owns the
|
|
11
12
|
// archive format and atomic restore; the shared runner owns its native children.
|
|
@@ -52,8 +53,10 @@ export function localPortability({ database, binding = 'DB', buckets = {}, polic
|
|
|
52
53
|
// Do not apply pending product migrations to a populated recovery target.
|
|
53
54
|
// Check real existing bindings first; finalize repeats this under the lock.
|
|
54
55
|
if(legacyRecovery)await nativeWorker(store,'legacy-preflight',input,signal);
|
|
56
|
+
// Local migrations need no provider account: wrangler runs without credentials.
|
|
57
|
+
const { dependencyEnvironment } = await shared('dependency-environment.mjs');
|
|
55
58
|
await store.run('Applying local data migrations', 'pnpm', ['exec', 'wrangler', 'd1', 'migrations', 'apply', database, '--local', '--persist-to', '.wrangler/state', '--config', 'wrangler.jsonc'], {
|
|
56
|
-
cwd: root, signal, env: { ...
|
|
59
|
+
cwd: root, signal, env: { ...dependencyEnvironment(), RER_LOCAL_OPERATION: operationToken, WRANGLER_SEND_METRICS: 'false' },
|
|
57
60
|
});
|
|
58
61
|
const receipt=await nativeWorker(store,direction,input,signal);
|
|
59
62
|
if(legacyRecovery)receipt.output=relative(root,legacyRecovery.output);
|
package/cli/scheduled.mjs
CHANGED
|
@@ -1,5 +1,3 @@
|
|
|
1
|
-
import { declaredCronSources } from './deploy-config.mjs';
|
|
2
|
-
|
|
3
1
|
// `rer dev` runs the project's declared Cron Triggers (wrangler `triggers.crons`, from wrangler.jsonc, wrangler.toml or a deploy template)
|
|
4
2
|
// on their schedule by calling the local runtime's scheduled test endpoint (the Vite plugin's under dev, Miniflare's own under preview), so
|
|
5
3
|
// alerts, retries and checks behave on the owner's computer as they do deployed.
|
|
@@ -40,6 +38,8 @@ export function cronMatches(cron, date) {
|
|
|
40
38
|
// triggers.crons must declare the same ones: otherwise `rer dev` would run a
|
|
41
39
|
// schedule the deployed Worker does not have.
|
|
42
40
|
export async function declaredCrons(root, adapter) {
|
|
41
|
+
// Imported here: the container image runs this file's parseCron and cronMatches without the CLI's project modules.
|
|
42
|
+
const { declaredCronSources } = await import('./deploy-config.mjs');
|
|
43
43
|
const sources = await declaredCronSources(root, adapter);
|
|
44
44
|
for (const { path, crons } of sources) for (const cron of crons) parseCron(cron);
|
|
45
45
|
const [first, ...others] = sources;
|
package/cli/setup-input.mjs
CHANGED
|
@@ -87,7 +87,7 @@ export async function setupInput(project, options, vars, signal, { origin } = {}
|
|
|
87
87
|
if(savedEmail&&!mailbox(savedEmail))throw refuse('Saved owner is malformed. Restore private settings or run rer reset --yes.');
|
|
88
88
|
if(savedEmail&&savedEmail.toLowerCase()!==answers[names.email]){
|
|
89
89
|
if(owners.length)throw refuse(`Private setting ${names.email} differs from the retained database owner. Restore ${names.email} in ${project.adapter.hooks?.secrets?.file??'.dev.vars'} to ${answers[names.email]}, then run rer setup --owner-email ${answers[names.email]}; or back up data and run rer reset --yes. Private bytes were kept.`);
|
|
90
|
-
throw refuse(`Owner conflicts with the saved installation (${savedEmail}). Run rer setup --owner-email ${savedEmail}, or back up data
|
|
90
|
+
throw refuse(`Owner conflicts with the saved installation (${savedEmail}). Run rer setup --owner-email ${savedEmail}, or back up data, run rer dev --stop, then rer reset --yes followed by rer setup --owner-email ${answers[names.email]}. Reset deletes local data.`);
|
|
91
91
|
}
|
|
92
92
|
|
|
93
93
|
try{return seed.model.validateInput(answers,known);}catch{throw missing('Installer answers are incomplete. Supply the active manifest fields using rer setup --answers answers.json --non-interactive.');}
|
package/global/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,51 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 0.2.4
|
|
4
|
+
|
|
5
|
+
0.2.3 was tagged but not published; its changes are in 0.2.4.
|
|
6
|
+
|
|
7
|
+
- A first `rer deploy` waits up to two minutes (it was 30 seconds) for Cloudflare's account listing to show a database, bucket, namespace or queue it has just created, whether the listing disagrees with its own page counts or leaves the new resource out; one that never appears stops the deploy before any upload, names the resource, and the same command resumes with it.
|
|
8
|
+
- A first `rer dev` no longer chooses the port another, stopped checkout was set up on, so both checkouts keep working origins.
|
|
9
|
+
- Seed recovery code can check the deploy migration ledger with one call: `migrationLedgerShape(rows)` from `@runeditrun/rer/modules/deploy-kit/migration-ledger`, given the rows of its `ledgerShapeQuery`, accepts all four ledger shapes a deployment can hold (Wrangler's `d1_migrations`, that table after a kit deploy adopted it, the kit's own `d1_migrations` since 0.2.2, and the `_rer_deploy_migrations` ledger a 0.2.1 or earlier deploy left, shape `legacy`, which wins when both tables exist) and refuses any other. A seed whose recovery checks that the ledger is exactly Wrangler's three columns refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`); replace that check with this call.
|
|
10
|
+
- `rer import` and hosted restores refuse an archive that records a migration this installation has not applied (`schema_mismatch`), even when that migration left the schema unchanged (an index or a data-only change); such an archive is newer than the installation.
|
|
11
|
+
- After a pulled change to a checkout's `@runeditrun/rer` pin, every verb (`rer deploy` included) installs the pinned version with the frozen lockfile before it runs, instead of asking you to run `rer install` first.
|
|
12
|
+
- Two verbs started at the same moment after a pulled pin change share one install of the new pin: the second waits for the first's install, then runs, instead of racing it and failing with "The declared adapter could not be loaded".
|
|
13
|
+
- The publication scan accepts AI-generated PNGs whose C2PA manifest carries the signing certificate's email address (certificate names are metadata) and phone JPEGs that store more images after the first (every image's pixels are skipped as pixels), while an address in the rest of the manifest, XMP or EXIF is still flagged.
|
|
14
|
+
- Dependency installs receive only the environment an install needs, never your credentials: the frozen `pnpm install` that `rer install`, a fresh clone's first verb, `rer dev`, the installer and `rer release` run, the pnpm version check, `rer update`'s npm install and local data migrations get `PATH`, `HOME`, `USER`, `LOGNAME`, `SHELL`, `TMPDIR`/`TMP`/`TEMP`, `TERM`, `LANG`/`LANGUAGE`/`LC_*`, `TZ`, `CI`, `SSH_AUTH_SOCK` (git+ssh dependencies), `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy settings without their user:password, and `npm_config_*`, `PNPM_*`, `COREPACK_*` and `RER_*` settings except credentials (by name, such as `_authToken` or `*_SESSION`, or by value: a URL with a password, a credential query or connection-string parameter, a JWT, a private key, an Authorization header or a provider token). Every other variable stays with you, so dependency build scripts (esbuild, workerd, sharp, oxide) never see vault sessions (`BW_SESSION`, `OP_SESSION_*`), connection strings, keys or tokens under any name. `rer deploy` and the seed's own `verify` still receive everything.
|
|
15
|
+
- A proxy setting reaches a dependency install without its user:password even when the password contains spaces, an extra `@` or the value starts with whitespace.
|
|
16
|
+
- `rer deploy` setting or removing a secret whose answer was lost settles against the Worker version it recorded, so a later deploy recognises the change instead of refusing it.
|
|
17
|
+
- A secret change whose answer was lost while the read-back also failed stays recorded as pending, so the next `rer deploy` recognises it instead of locking you out; replacing an existing secret after a lost answer writes it again and confirms it before reporting success.
|
|
18
|
+
- After it claims the hosted owner, retires the setup token or sets or removes a secret, `rer deploy` checks the app's health on the new Worker version and fails with the HTTP status and the response's first line, instead of reporting success, when the app is not healthy.
|
|
19
|
+
- `rer setup` finishes provider-free when a seed declares an optional dependency that you leave unconnected.
|
|
20
|
+
- A seed adapter can declare `resetPreflight`; `rer reset` runs it before listing or deleting anything, and `--yes` cannot skip it.
|
|
21
|
+
- A first `rer dev` whose chosen port is taken by another program before setup can use it moves to the next free port and says which.
|
|
22
|
+
- `rer deploy` says it is deploying to your Cloudflare account and names the live address, instead of saying the application is running locally; `rer build` ends with "Your application is built".
|
|
23
|
+
- `rer describe` in the default seed and new foundations names the release keygen, sign and receipt subcommands again.
|
|
24
|
+
- Importing an archive whose bytes are not valid UTF-8 is refused with the same `archive_json` diagnostic (exit 2) as invalid JSON.
|
|
25
|
+
- Converting a seed to package mode no longer leaves a broken queue sentence in its README or `scripts/base-cli` paths in its operations guide and erasure README.
|
|
26
|
+
- The owner's sign-in settings page (`/auth/settings/identity`) wears the seed's declared theme, like sign-in and setup.
|
|
27
|
+
- `rer demo` (the same as `rer dev --demo`) runs a seed as a throwaway local demo: signed in as the saved owner, filled with the seed's demo data, marked by a Demo bar, unable to send anything, and deleted on `--stop`, Ctrl-C or after an hour; the checkout's own state is never written.
|
|
28
|
+
- Under `rer demo`, `fetch`, redirects, `WebSocket`, `navigator.sendBeacon`, `EventSource`, `node:net`/`node:tls` and `cloudflare:sockets` reach loopback hosts only, and every refused send is listed in the Demo bar.
|
|
29
|
+
- `rer demo` refuses a seed that has not adopted it (no `withDemo` guard, no `demoSockets()` or a state path that ignores `RER_STATE_DIR`), and `rer deploy`, including `--dry-run`, refuses a deploy declaration or `--env-file` that carries `RER_DEMO` and names the key.
|
|
30
|
+
- `rer deploy --demo` hosts a seed's demo at `demo.<domain>`: each visitor passes a Turnstile check and gets their own signed-in, throwaway container of the seed that can send nothing, ended after an hour or 15 idle minutes. `export/run-demo.yml` deploys it when `RUN_DEMO_DEPLOY` is on.
|
|
31
|
+
- `rer deploy` claims the hosted owner after every deploy, including seeds whose own deploy hooks run, retires the setup token, keeps an existing native identity-provider owner and keeps a live owner token you supply.
|
|
32
|
+
- Container images built by `rer build --platform container` start again; they exited at start.
|
|
33
|
+
- The default seed's sign-in serves the signed owner claim whenever its public keys are configured.
|
|
34
|
+
- A seed can declare a reviewed rewrite of an already released migration in `migrations/rewrites.json`; deploys and local builds accept the released bytes for that migration only and still refuse every undeclared change.
|
|
35
|
+
- Seed table inventories can use the exported portability exclusion list, so native ZIP and remote export no longer refuse the deploy controller's migration ledger table.
|
|
36
|
+
- The publication scan skips only the compressed pixel and sample data of recognised images and videos; their comments, XMP, EXIF and tags are checked, and a text file behind a fake media header is checked whole.
|
|
37
|
+
- With `--public-base`, the publication scan reports a file identical to the public base's copy as pre-existing instead of a finding.
|
|
38
|
+
- Base's default seed and source import kit (outside the rer npm package) prepare and verify 100,000-row imports across requests within the Worker CPU limit and resume after a killed step; a changed re-upload under a published key is refused.
|
|
39
|
+
- Base's default seed and source import kit erase a person named in other people's imported records at seed scale, across requests, and when they never became a native record; an interrupted erasure is finished by a cron sweep within about 10 minutes.
|
|
40
|
+
|
|
41
|
+
### Upgrade notes
|
|
42
|
+
|
|
43
|
+
- Existing OIDC provider links show **not linked** until the next successful sign-in through each provider, because older stores have no verified token-exchange observation for them.
|
|
44
|
+
- The default seed's erasure queue message is now `{ redact, chain }` instead of `{ redact, operator, continuation }`; a message still queued in the old shape when you deploy is refused, so let the erasure queue drain first. Default-seed migrations 0021 to 0023 add the import-step, stream-source and erasure-progress tables, and its cron trigger sweeps erasures every 10 minutes.
|
|
45
|
+
- A seed adopting `rer demo` adds `src/demo.ts` (exporting an idempotent `seedDemo`), copies Base's `modules/demo/` and wraps its Worker entry's default export in `withDemo` as the entry's first import, and makes three `vite.config` changes: the state path reads `RER_STATE_DIR`, the dev entry's vars pass `RER_DEMO` and `RER_DEMO_EXPIRES`, and `demoSockets()` comes first in `plugins`; add `.rer/demo/` to `.gitignore`. `export/DEMO.md` has the steps.
|
|
46
|
+
- A private registry's auth must be in `~/.npmrc` (or the registry config your package manager reads from `HOME`): dependency installs now get only an allowlisted environment, so a token passed as an environment variable no longer reaches them.
|
|
47
|
+
- A seed with custom recovery code that checks the deploy migration ledger is exactly Wrangler's three columns uses `migrationLedgerShape` instead; the old check refuses ledgers from 0.2.2 on (`MIGRATION_MISMATCH`).
|
|
48
|
+
|
|
3
49
|
## 0.2.2
|
|
4
50
|
|
|
5
51
|
- `rer deploy` claims the hosted owner, exits cleanly when it finishes, changes and removes secrets safely, and removes the resources it created when you ask it to.
|
package/global/README.md
CHANGED
|
@@ -182,8 +182,15 @@ as an exact devDependency instead (`"@runeditrun/rer": "0.2.0"`, locked in
|
|
|
182
182
|
which inside `pnpm run` is the pinned copy in `node_modules/.bin`, so
|
|
183
183
|
`pnpm install --frozen-lockfile && pnpm run build` needs no global `rer`. A global
|
|
184
184
|
`rer` hands every verb to the pinned package, launching included. Until
|
|
185
|
-
`node_modules` holds the pinned version
|
|
186
|
-
first (the frozen `pnpm install`)
|
|
185
|
+
`node_modules` holds the pinned version (a fresh clone, or a pulled pin change),
|
|
186
|
+
every verb installs it first (the frozen `pnpm install`), then runs it; verbs
|
|
187
|
+
started at once share that one install.
|
|
188
|
+
Every dependency install, `rer release`'s included, receives an allowlisted
|
|
189
|
+
environment: the shell, locale, terminal and temporary-folder variables, `CI`,
|
|
190
|
+
`TZ`, `SSH_AUTH_SOCK`, `NODE_OPTIONS`, `NODE_EXTRA_CA_CERTS`, `XDG_*`, proxy
|
|
191
|
+
settings without their user:password, and `npm_config_*`, `PNPM_*`,
|
|
192
|
+
`COREPACK_*` and `RER_*` settings that carry no credential by name or value.
|
|
193
|
+
No other variable reaches the package manager or a dependency's build script.
|
|
187
194
|
|
|
188
195
|
Heavy package scripts wait for this computer's job queue with
|
|
189
196
|
`rer queue NAME -- COMMAND [ARGS]` (in vendored mode,
|
|
@@ -13,7 +13,7 @@ import { LaunchFailure } from './failure.mjs';
|
|
|
13
13
|
export const personDefaults = { limit: 10, idleSeconds: 43200 };
|
|
14
14
|
const unboundedLoad = '1000000';
|
|
15
15
|
// The verbs that start a server or enter the job queue on every pin.
|
|
16
|
-
export const personVerbs = new Set(['dev', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
|
|
16
|
+
export const personVerbs = new Set(['dev', 'demo', 'preview', 'install', 'setup', 'build', 'verify', 'deploy', 'export', 'import']);
|
|
17
17
|
|
|
18
18
|
// How the pinned dispatcher counts servers, and which person defaults it lacks.
|
|
19
19
|
// describe's `defaults` (from rer-cli-2) is authoritative: the launcher supplies
|