@runeditrun/rer 0.2.1 → 0.2.2
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 +12 -2
- package/cli/auth-copy.mjs +25 -18
- package/cli/bounded.mjs +13 -4
- package/cli/contract.mjs +1 -1
- package/cli/deploy-inputs.mjs +33 -0
- package/cli/deploy-owner.mjs +61 -0
- package/cli/deploy.mjs +87 -15
- package/cli/door.mjs +1 -1
- package/cli/inspect.mjs +24 -32
- package/cli/lifecycle.mjs +14 -4
- package/cli/parse.mjs +3 -1
- package/cli/pnpm-probe.mjs +14 -0
- package/cli/pnpm-runtime.mjs +22 -22
- package/cli/portability-policy.mjs +55 -0
- package/cli/portability-worker.mjs +8 -4
- package/cli/portability.mjs +20 -4
- package/cli/project-pnpm.mjs +67 -0
- package/cli/release.mjs +35 -0
- package/cli/server-registry.mjs +2 -2
- package/cli/site-check.mjs +15 -6
- package/cli/site-data.mjs +6 -5
- package/cli/site-references.mjs +153 -3
- package/global/CHANGELOG.md +14 -0
- package/global/README.md +12 -2
- package/global/cli/install.mjs +26 -9
- package/global/cli/path-link.mjs +30 -0
- package/global/cli/status.mjs +1 -1
- package/global/cli/uninstall.mjs +5 -1
- package/global/launch/install-pinned.mjs +4 -0
- package/global/main.mjs +28 -3
- package/global/registry.mjs +2 -2
- package/modules/container/deploy-input.mjs +5 -9
- package/modules/dependencies/auth.mjs +2 -1
- package/modules/deploy-kit/README.md +25 -7
- package/modules/deploy-kit/d1-import.mjs +35 -0
- package/modules/deploy-kit/deploy.mjs +4 -3
- package/modules/deploy-kit/diagnostics.mjs +11 -1
- package/modules/deploy-kit/env-file.mjs +26 -0
- package/modules/deploy-kit/health-request.mjs +6 -1
- package/modules/deploy-kit/health.mjs +35 -15
- package/modules/deploy-kit/migration-ledger.mjs +48 -0
- package/modules/deploy-kit/migrations.mjs +11 -15
- package/modules/deploy-kit/owner-cli.mjs +20 -7
- package/modules/deploy-kit/owner-remove.mjs +105 -0
- package/modules/deploy-kit/owner-secret.mjs +49 -0
- package/modules/deploy-kit/owner.mjs +68 -10
- package/modules/deploy-kit/provider.mjs +15 -2
- package/modules/job-queue/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/README.md +18 -0
- package/modules/operator-auth/identity/oidc/config.mjs +4 -2
- package/modules/operator-auth/identity/oidc/index.d.mts +1 -1
- package/modules/operator-auth/identity/oidc/index.mjs +28 -9
- package/modules/operator-auth/identity/oidc/network.mjs +9 -4
- package/modules/operator-auth/identity/oidc/store.mjs +19 -3
- package/modules/operator-auth/index.d.mts +2 -0
- package/modules/operator-auth/routes.mjs +1 -1
- package/modules/portability/README.md +28 -0
- package/modules/portability/bridge.mjs +10 -3
- package/modules/portability/import.mjs +4 -2
- package/onboarding/commands.mjs +3 -1
- package/package.json +1 -1
- package/scripts/publish-check/publication-config.json +7 -1
package/README.md
CHANGED
|
@@ -22,8 +22,11 @@ refuses archives with absolute paths, `..` entries, links or devices, then runs
|
|
|
22
22
|
the package's own `rer cli install`. A failed or refused download leaves the
|
|
23
23
|
version you had selected.
|
|
24
24
|
|
|
25
|
-
No installer edits shell profiles.
|
|
26
|
-
|
|
25
|
+
No installer edits shell profiles. The canonical launcher is `~/.local/bin/rer`.
|
|
26
|
+
When that directory is absent from PATH, installation also links it into an
|
|
27
|
+
existing writable, user-owned PATH directory if available, preserving foreign
|
|
28
|
+
commands. Otherwise it prints one activation line to run in the current shell:
|
|
29
|
+
`export PATH="$HOME/.local/bin:$PATH"` (with fish syntax for fish). The launcher runs `node` from
|
|
27
30
|
PATH, so a Node upgrade does not break it. A re-run with the same build changes
|
|
28
31
|
nothing; a different build is installed beside the current one, the previous one
|
|
29
32
|
is kept and older ones are removed. It refuses to replace a `~/.local/bin/rer`
|
|
@@ -267,3 +270,10 @@ Anywhere: `rer list [DIR]`, `rer cli status|install|uninstall`, `rer update`,
|
|
|
267
270
|
`rer queue NAME -- COMMAND`, `rer --version` and `rer --help`.
|
|
268
271
|
|
|
269
272
|
Licence: MIT.
|
|
273
|
+
|
|
274
|
+
First-party commands acquire the exact `packageManager` pnpm pin when PATH lacks
|
|
275
|
+
it, verify its registry sha512 integrity and cache it under `$HOME/.rer/pnpm`.
|
|
276
|
+
No global package manager installation or sudo is needed. Independent projects
|
|
277
|
+
continue to run their declared commands with their own tools. Server and job
|
|
278
|
+
state default to `$HOME/.rer/servers` and `$HOME/.rer/jobs`; supplied
|
|
279
|
+
`RER_SERVERS_DIR` and `RER_JOBS_DIR` take precedence and are passed to older pins.
|
package/cli/auth-copy.mjs
CHANGED
|
@@ -7,16 +7,17 @@ import { contained } from './project.mjs';
|
|
|
7
7
|
// bytes of the Base revision it pinned. A seed records its copies in
|
|
8
8
|
// base-copies.json (edition authoring) or composition.lock.json /
|
|
9
9
|
// foundation.lock.json (composed foundations); each records the SHA-256 of the
|
|
10
|
-
// Base bytes it copied.
|
|
11
|
-
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
15
|
-
// imports Base in place has no copy.
|
|
10
|
+
// Base bytes it copied. Application runtime and schema sources share one pin;
|
|
11
|
+
// the CLI's bundled tooling has its own. Numbered SQL under migrations keeps
|
|
12
|
+
// its historical revision independently: refreshing runtime must not rewrite
|
|
13
|
+
// SQL a database may already have applied. Every copy still needs a valid pin
|
|
14
|
+
// and exact bytes. A seed that imports Base in place has no copy.
|
|
16
15
|
const ORIGIN = 'modules/operator-auth';
|
|
17
16
|
const CLI_ROOT = 'scripts/base-cli';
|
|
18
17
|
const within = (path, prefix) => path === prefix || path.startsWith(`${prefix}/`);
|
|
19
|
-
const groupOf = path => typeof path === 'string' && within(path, CLI_ROOT) ? 'cli'
|
|
18
|
+
const groupOf = path => typeof path === 'string' && within(path, CLI_ROOT) ? 'cli'
|
|
19
|
+
: /(?:^|\/)migrations\/(?:[^/]+\/)*\d+[^/]*\.sql$/.test(path) ? 'migration' : 'application';
|
|
20
|
+
const pinned = revision => typeof revision === 'string' && /^[a-f0-9]{40}$/.test(revision);
|
|
20
21
|
const sha256 = bytes => createHash('sha256').update(bytes).digest('hex');
|
|
21
22
|
async function json(root, path) { try { return JSON.parse(await readFile(await contained(root, path), 'utf8')); } catch { return null; } }
|
|
22
23
|
|
|
@@ -30,22 +31,24 @@ async function walk(root, path) {
|
|
|
30
31
|
return files;
|
|
31
32
|
}
|
|
32
33
|
|
|
33
|
-
// [{
|
|
34
|
+
// [{ path, revision, files: { local path: sha256 } }] for every recorded auth copy.
|
|
34
35
|
async function recordedCopies(root) {
|
|
35
36
|
const copies = [];
|
|
36
37
|
const record = await json(root, 'base-copies.json');
|
|
37
38
|
for (const copy of record?.copies ?? []) {
|
|
38
|
-
if (typeof copy?.originPath
|
|
39
|
+
if (typeof copy?.originPath !== 'string' || !within(copy.originPath, ORIGIN)) continue;
|
|
40
|
+
const files = copy.files && typeof copy.files === 'object' && !Array.isArray(copy.files) ? copy.files : {};
|
|
41
|
+
copies.push({ path: copy.path, revision: copy.revision, files, directory: copy.originPath === ORIGIN || Object.keys(files).some(path => path !== copy.path && within(path, copy.path)) });
|
|
39
42
|
}
|
|
40
43
|
for (const name of ['composition.lock.json', 'foundation.lock.json']) {
|
|
41
44
|
const lock = await json(root, name);
|
|
42
45
|
if (!lock?.files) continue;
|
|
43
46
|
const revision = lock.repositories?.base?.commit ?? lock.base?.commit;
|
|
44
|
-
for (const
|
|
45
|
-
const entries = Object.entries(lock.files).filter(([path, file]) => groupOf(path) ===
|
|
46
|
-
const files = Object.fromEntries(entries.
|
|
47
|
+
for (const cli of [false, true]) {
|
|
48
|
+
const entries = Object.entries(lock.files).filter(([path, file]) => (groupOf(path) === 'cli') === cli && typeof file?.source === 'string' && within(file.source, ORIGIN));
|
|
49
|
+
const files = Object.fromEntries(entries.map(([path, file]) => [path, file.sha256]));
|
|
47
50
|
const directories = [...new Set(entries.filter(([, file]) => !file.source.endsWith('.sql')).map(([path, file]) => path.slice(0, path.length - (file.source.length - ORIGIN.length))))];
|
|
48
|
-
if (Object.keys(files).length) copies.push({ path: directories[0] ?? null, revision, files, directory: directories.length === 1
|
|
51
|
+
if (Object.keys(files).length) copies.push({ path: directories[0] ?? null, revision, files, directory: directories.length === 1 });
|
|
49
52
|
}
|
|
50
53
|
}
|
|
51
54
|
return copies;
|
|
@@ -55,18 +58,22 @@ export async function authCopyClauses(root) {
|
|
|
55
58
|
const copies = await recordedCopies(root);
|
|
56
59
|
if (!copies.length) return [];
|
|
57
60
|
const clauses = [];
|
|
58
|
-
for (const [group, id, scope] of [['application', 'SC-05.auth-copy.pin', 'the application
|
|
59
|
-
const grouped = copies.filter(copy => copy.
|
|
61
|
+
for (const [group, id, scope] of [['application', 'SC-05.auth-copy.pin', 'the application runtime and schema sources'], ['cli', 'SC-05.auth-copy.cli-pin', `the CLI's bundled copy under ${CLI_ROOT}`]]) {
|
|
62
|
+
const grouped = copies.filter(copy => Object.keys(copy.files).some(path => groupOf(path) === group));
|
|
60
63
|
if (!grouped.length) continue;
|
|
61
64
|
const revisions = [...new Set(grouped.map(copy => copy.revision))];
|
|
62
|
-
clauses.push([id, revisions.length === 1 &&
|
|
65
|
+
clauses.push([id, revisions.length === 1 && pinned(revisions[0]), `Copy ${scope} of operator-auth from one pinned Base revision; the seed records ${revisions.join(', ') || 'none'}.`]);
|
|
63
66
|
}
|
|
64
67
|
for (const copy of copies) {
|
|
68
|
+
if (!Object.keys(copy.files).length) clauses.push([`SC-05.auth-copy.${copy.path}`, false, `${copy.path} has no recorded bytes; record its copied files and their SHA-256 digests.`]);
|
|
65
69
|
for (const [path, expected] of Object.entries(copy.files)) {
|
|
70
|
+
if (groupOf(path) === 'migration') clauses.push([`SC-05.auth-copy.migration-pin.${path}`, pinned(copy.revision), `${path} must retain a valid pinned Base revision for its historical SQL; the seed records ${copy.revision ?? 'none'}.`]);
|
|
66
71
|
let actual = null; try { actual = sha256(await readFile(await contained(root, path))); } catch {}
|
|
67
|
-
clauses.push([`SC-05.auth-copy.${path}`, actual === expected, actual === null
|
|
72
|
+
clauses.push([`SC-05.auth-copy.${path}`, actual !== null && actual === expected, actual === null
|
|
68
73
|
? `${path} is missing; restore it from Base ${copy.revision}.`
|
|
69
|
-
:
|
|
74
|
+
: groupOf(path) === 'migration'
|
|
75
|
+
? `${path} differs from Base ${copy.revision}; restore its historical bytes and put schema changes in a new migration.`
|
|
76
|
+
: `${path} differs from Base ${copy.revision}; restore the copied bytes or record a new pin with refresh-base.`]);
|
|
70
77
|
}
|
|
71
78
|
if (copy.directory && copy.path) {
|
|
72
79
|
for (const path of await walk(root, copy.path)) {
|
package/cli/bounded.mjs
CHANGED
|
@@ -16,7 +16,7 @@ export async function boundedCommand(project,options,argv,out){
|
|
|
16
16
|
const policy=await commandPolicy();
|
|
17
17
|
const childArgs=[...argv];const cwdIndex=childArgs.indexOf('--cwd');if(cwdIndex>=0&&(childArgs.indexOf('--')<0||cwdIndex<childArgs.indexOf('--')))childArgs[cwdIndex+1]=project.root;
|
|
18
18
|
const directory=await mkdtemp(join(tmpdir(),'rer-bounded-'));
|
|
19
|
-
let store,result,finalEvent,errorEvent,buffer='';
|
|
19
|
+
let store,result,finalEvent,errorEvent,buffer='';const unclosed=[];
|
|
20
20
|
const cancellation=new AbortController();
|
|
21
21
|
const handlers=new Map(['SIGINT','SIGTERM'].map(name=>[name,()=>cancellation.abort(name)]));
|
|
22
22
|
for(const [name,handler] of handlers)process.on(name,handler);
|
|
@@ -47,11 +47,18 @@ export async function boundedCommand(project,options,argv,out){
|
|
|
47
47
|
const held=await recoverInterrupted(project.root);await held?.release();
|
|
48
48
|
}
|
|
49
49
|
}
|
|
50
|
+
}).catch(error=>{
|
|
51
|
+
// The workflow already reported its outcome; only stopping its process group
|
|
52
|
+
// failed. Keep that outcome, and say so, instead of losing it.
|
|
53
|
+
if(!finalEvent||cancellation.signal.aborted)throw error;
|
|
54
|
+
unclosed.push(error);return {code:finalEvent.exitCode};
|
|
50
55
|
});
|
|
51
56
|
}finally{
|
|
52
|
-
|
|
53
|
-
|
|
57
|
+
// Every closer runs: a policy socket left listening keeps this process alive.
|
|
58
|
+
for(const close of [()=>store?.closeCommands(),()=>policy.close(),()=>rm(directory,{recursive:true,force:true})]){try{await close();}catch(error){unclosed.push(error);}}
|
|
59
|
+
for(const [name,handler] of handlers)process.off(name,handler);
|
|
54
60
|
}
|
|
61
|
+
if(unclosed.length)out.progress(`Local cleanup could not confirm that every helper process stopped (${unclosed.map(error=>store?.redact?.(String(error?.message))??'unknown cause').join('; ')}). The ${options.verb} itself finished.\n`);
|
|
55
62
|
const termination=cancellation.signal.aborted?cancellation.signal.reason:undefined;
|
|
56
63
|
if(!result&&!termination)return null;
|
|
57
64
|
const exitCode=termination?(termination==='SIGINT'?130:143):result.code;
|
|
@@ -61,7 +68,9 @@ export async function boundedCommand(project,options,argv,out){
|
|
|
61
68
|
}else if(finalEvent&&finalEvent.exitCode===exitCode){
|
|
62
69
|
if(errorEvent)out.error(new RerError(errorEvent.code,store.redact(errorEvent.message),exitCode));
|
|
63
70
|
const {version,verb,event,ok,exitCode:childExit,...details}=finalEvent;
|
|
64
|
-
|
|
71
|
+
// Redaction can cut through a value that matches a credential; the outcome stands without its details.
|
|
72
|
+
let shown;try{shown=JSON.parse(store.redact(JSON.stringify(details)));}catch{shown={};}
|
|
73
|
+
out.result(exitCode===0,exitCode,shown);
|
|
65
74
|
}else{
|
|
66
75
|
const error=new RerError('command_failed',result.supervisorEnded?store.redact(result.error):'Bounded command ended before its result; owned children and private files were recovered.',exitCode||1);
|
|
67
76
|
out.error(error);out.result(false,error.exitCode);return {exitCode:error.exitCode};
|
package/cli/contract.mjs
CHANGED
|
@@ -11,7 +11,7 @@ const definitions = {
|
|
|
11
11
|
build: [['--site'], 'Production build with private local settings excluded and restored. --site instead writes the static run site (homepage, /run, docs) to its declared folder and prints that folder.'],
|
|
12
12
|
verify: [[], 'Run the complete internal verification target.'],
|
|
13
13
|
reset: [['--yes'], 'Confirmed removal of the declared local inventory only.'],
|
|
14
|
-
deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port'], 'Owner-account deployment and health; dry run performs local validation/build only. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
|
|
14
|
+
deploy: [['--environment', '--dry-run', '--site', '--host', '--env-file', '--image', '--bind-address', '--http-port', '--https-port', '--remove', '--yes', '--set-secret', '--unset-secret'], 'Owner-account deployment and health; dry run performs local validation/build only. Declared secrets come from the environment or --env-file (an owned 0600 KEY=VALUE file); a new Worker gets the local setup owner and fresh generated secrets when they are not given. After a healthy deploy rer claims the setup owner on the hosted app with the one-time setup token, then removes the token. --set-secret NAME (value from the environment or --env-file) and --unset-secret NAME change one secret of the deployed Worker without a build and record its new version. --remove --yes deletes what this environment\'s deploy record says rer created (Worker, D1, R2, KV, queues), reads back that each is gone, and touches nothing else. --site deploys the run site instead: an assets-only Worker on its custom domain with www redirected; with --dry-run it builds the site and prints the plan with no provider request.'],
|
|
15
15
|
export: [['--target', '--output', '--origin', '--session-file'], 'Complete supported installation archive with integrity and exclusions.'],
|
|
16
16
|
import: [['--target', '--input', '--journal', '--origin', '--session-file', '--yes', '--legacy-v1', '--acknowledge-unverified-metadata', '--output'], 'Confirmed atomic restore into an empty destination; --journal LATER_ARCHIVE also fences rows that installation erased since. Backup-only local v1 recovery requires --legacy-v1 --acknowledge-unverified-metadata and a new --output v2 archive; --journal requires normal v2 import.'],
|
|
17
17
|
outbox: [['--port', '--host', '--owner-email', '--open'], 'Credential-free local outbox URL; optional ordinary sign-in request and explicit browser opening.'],
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
import { shared } from './shared.mjs';
|
|
3
|
+
import { invalid } from './errors.mjs';
|
|
4
|
+
import { readDeployEnvFile } from '../modules/deploy-kit/env-file.mjs';
|
|
5
|
+
|
|
6
|
+
// The values a Workers deploy reads for its declared secrets: this process's
|
|
7
|
+
// environment, then the --env-file (which wins), then what rer supplies itself:
|
|
8
|
+
// the local setup owner's email, and a fresh value for each secret the setup
|
|
9
|
+
// manifest generates (a session secret, the one-time setup token). A value only
|
|
10
|
+
// reaches a Worker that lacks that secret; an existing one is kept unchanged.
|
|
11
|
+
export async function deployEnvironment({ root, options, declaration, setupManifest, settings, localOnlyKeys = [] }) {
|
|
12
|
+
const env = { ...process.env }, supplied = [];
|
|
13
|
+
if (options.envFile) {
|
|
14
|
+
let values;
|
|
15
|
+
try { values = await readDeployEnvFile(root, options.envFile, localOnlyKeys); }
|
|
16
|
+
catch (error) { throw invalid(`The --env-file ${options.envFile} could not be used: ${error.code === 'ENOENT' ? 'no such file' : error.message}.`); }
|
|
17
|
+
Object.assign(env, values);
|
|
18
|
+
}
|
|
19
|
+
const secrets = Array.isArray(declaration.secrets) ? declaration.secrets : [];
|
|
20
|
+
const absent = item => item && !env[item.environment]?.trim();
|
|
21
|
+
const emailField = setupManifest?.steps?.find(step => step.type === 'owner-claim')?.emailField;
|
|
22
|
+
const email = secrets.find(item => item?.binding === emailField);
|
|
23
|
+
if (absent(email) && settings) {
|
|
24
|
+
const { readSettings } = await shared('adapter-settings.mjs');
|
|
25
|
+
const owner = (await readSettings(root, settings)).values[emailField];
|
|
26
|
+
if (typeof owner === 'string' && owner.includes('@')) { env[email.environment] = owner; supplied.push(email.binding); }
|
|
27
|
+
}
|
|
28
|
+
for (const field of setupManifest?.fields ?? []) {
|
|
29
|
+
const item = field.kind === 'secret' && field.generate ? secrets.find(entry => entry?.binding === field.name) : undefined;
|
|
30
|
+
if (absent(item)) { env[item.environment] = randomBytes(32).toString(field.generate); supplied.push(item.binding); }
|
|
31
|
+
}
|
|
32
|
+
return { env, supplied };
|
|
33
|
+
}
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { randomBytes } from 'node:crypto';
|
|
2
|
+
|
|
3
|
+
// After a healthy deploy, the hosted app's first owner is the setup owner: rer
|
|
4
|
+
// claims it with the one-time setup token through the seed's standard owner step
|
|
5
|
+
// (Base operator-auth POST /auth/bootstrap), then removes that token from the
|
|
6
|
+
// Worker and records the new Worker version. A Worker still holding a token rer
|
|
7
|
+
// did not upload this run gets a fresh one first, unless the person supplied the
|
|
8
|
+
// token (environment or --env-file): that value is the deployed token and is used. A seed with its own owner step
|
|
9
|
+
// function, an identity-provider claim, or no declared token secret is left to it.
|
|
10
|
+
// `beforeClaim(token)` runs while the token is live (dependency approvals need it).
|
|
11
|
+
export async function claimHostedOwner({ hook, setupManifest, declaration, deployed, env, supplied = [], secret, addSecret, beforeClaim, fetch = globalThis.fetch, sleep = ms => new Promise(resolve => setTimeout(resolve, ms)), boundMs = 90000 }) {
|
|
12
|
+
if (!hook || typeof hook !== 'object' || Array.isArray(hook) || !deployed.origin) return undefined;
|
|
13
|
+
const claim = setupManifest?.steps?.find(step => step.type === 'owner-claim');
|
|
14
|
+
if (claim?.mode === 'idp') return undefined;
|
|
15
|
+
const tokenKey = hook.tokenKey ?? claim?.tokenField ?? 'SETUP_TOKEN';
|
|
16
|
+
const item = (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(entry => entry?.binding === tokenKey);
|
|
17
|
+
if (!item || deployed.retiredSecrets?.includes(tokenKey)) return undefined;
|
|
18
|
+
const given = env[item.environment]?.trim() && !supplied.includes(tokenKey);
|
|
19
|
+
let token = deployed.sentSecrets?.includes(tokenKey) || given ? env[item.environment] : undefined;
|
|
20
|
+
if (!token) {
|
|
21
|
+
token = randomBytes(32).toString('hex'); addSecret(token);
|
|
22
|
+
const { versionId } = await secret(['--set', tokenKey], { ...env, [item.environment]: token });
|
|
23
|
+
// A secret change is a new Worker version; claim once the app answers from it.
|
|
24
|
+
await servingVersion({ origin: deployed.origin, versionId, fetch, sleep, boundMs });
|
|
25
|
+
}
|
|
26
|
+
await beforeClaim?.(token);
|
|
27
|
+
const displayName = (typeof hook.displayName === 'function' ? hook.displayName({}) : undefined) || 'Owner';
|
|
28
|
+
let response, code;
|
|
29
|
+
// Edge locations can briefly answer from the previous version, which lacks this token.
|
|
30
|
+
for (let attempt = 1; ; attempt++) {
|
|
31
|
+
try {
|
|
32
|
+
response = await fetch(new URL(hook.path ?? '/auth/bootstrap', deployed.origin), { method: 'POST', redirect: 'manual', signal: AbortSignal.timeout(30000),
|
|
33
|
+
headers: { Origin: deployed.origin, 'Content-Type': 'application/json' }, body: JSON.stringify({ token, displayName, returnTo: hook.returnTo ?? '/' }) });
|
|
34
|
+
} catch { throw ownerFailure('the hosted application did not answer'); }
|
|
35
|
+
code = 'no code';
|
|
36
|
+
if (response.status !== 303) { try { const body = await response.json(); if (typeof body?.error === 'string' && /^[a-z_]{1,64}$/.test(body.error)) code = body.error; } catch {} }
|
|
37
|
+
else await response.body?.cancel();
|
|
38
|
+
if (response.status !== 403 || code !== 'invalid_setup_token' || attempt === 3) break;
|
|
39
|
+
await sleep(5000);
|
|
40
|
+
}
|
|
41
|
+
const outcome = response.status === 303 ? 'claimed' : response.status === 409 && code === 'already_initialized' ? 'kept' : undefined;
|
|
42
|
+
if (!outcome) throw ownerFailure(`HTTP ${response.status} (${code})${code === 'invalid_setup_token' && given ? `; the ${item.environment} you supplied is not the deployed setup token: leave it unset and rer issues a fresh one` : ''}`);
|
|
43
|
+
await secret(['--unset', tokenKey], env);
|
|
44
|
+
return outcome;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
async function servingVersion({ origin, versionId, fetch, sleep, boundMs }) {
|
|
48
|
+
const deadline = Date.now() + boundMs;
|
|
49
|
+
while (true) {
|
|
50
|
+
try {
|
|
51
|
+
const response = await fetch(new URL('/health', origin), { redirect: 'manual', signal: AbortSignal.timeout(10000), headers: { 'Cache-Control': 'no-cache', Accept: 'application/json' } });
|
|
52
|
+
if ((await response.json().catch(() => null))?.deployment?.versionId === versionId) return;
|
|
53
|
+
} catch {}
|
|
54
|
+
if (Date.now() >= deadline) throw ownerFailure(`the hosted app did not answer from the Worker version holding the new setup token within ${Math.round(boundMs / 1000)} seconds`);
|
|
55
|
+
await sleep(2000);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function ownerFailure(cause) {
|
|
60
|
+
const error = new Error('owner-claim-failed'); error.cause = cause; return error;
|
|
61
|
+
}
|
package/cli/deploy.mjs
CHANGED
|
@@ -1,24 +1,33 @@
|
|
|
1
1
|
import { readFile } from 'node:fs/promises';
|
|
2
|
-
import { resolve } from 'node:path';
|
|
3
2
|
import { deployDiagnostic } from '../modules/deploy-kit/diagnostics.mjs';
|
|
4
3
|
import { shared } from './shared.mjs';
|
|
5
4
|
import { contained } from './project.mjs';
|
|
6
|
-
import { missing, invalid, refuse } from './errors.mjs';
|
|
5
|
+
import { missing, invalid, refuse, RerError } from './errors.mjs';
|
|
7
6
|
import { ownerPlan, ownerWorkerName, localOnlyBinding } from '../modules/deploy-kit/owner.mjs';
|
|
8
7
|
import { packageBuild } from '../modules/deploy-kit/prepare.mjs';
|
|
9
8
|
import { refuseLocalModels } from '../modules/deploy-kit/local-only.mjs';
|
|
9
|
+
import { deployEnvironment } from './deploy-inputs.mjs';
|
|
10
|
+
import { claimHostedOwner } from './deploy-owner.mjs';
|
|
10
11
|
|
|
11
12
|
export function ownerDeploy(declarationPath, lifecycle) {
|
|
12
13
|
// rer verify serves the build with this declaration's vars and secrets.
|
|
13
|
-
return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {} }) => {
|
|
14
|
+
return Object.assign(async ({ root, options, store, signal, operationToken, localOnlyKeys = [], checkBuild = async () => {}, setupManifest, linkDependencies }) => {
|
|
14
15
|
try { await refuseLocalModels(root); } catch { throw refuse('Deploy refuses the local-only key file .rer/local/models.json. Remove live-dev credentials before deployment.'); }
|
|
15
16
|
const path = await contained(root, declarationPath);
|
|
16
17
|
let declaration; try { declaration = JSON.parse(await readFile(path, 'utf8')); } catch { throw invalid('Malformed owner-account deploy declaration.'); }
|
|
17
18
|
const local = localOnlyBinding(declaration, localOnlyKeys);
|
|
18
19
|
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.`);
|
|
19
20
|
const environment = options.environment ?? 'production';
|
|
20
|
-
|
|
21
|
-
|
|
21
|
+
const again = `rer deploy --environment ${environment}`;
|
|
22
|
+
const change = [options.remove && '--remove', options.setSecret && '--set-secret', options.unsetSecret && '--unset-secret'].filter(Boolean);
|
|
23
|
+
// The command that finishes what this run started: the same verb, never a plain deploy in its place.
|
|
24
|
+
const retry = options.remove ? `${again} --remove --yes` : options.setSecret ? `${again} --set-secret ${options.setSecret}` : options.unsetSecret ? `${again} --unset-secret ${options.unsetSecret}` : again;
|
|
25
|
+
if (change.length > 1 || change.length && options.dryRun) throw invalid(`Use ${change.join(', ')}${options.dryRun ? ' and --dry-run' : ''} on separate runs.`);
|
|
26
|
+
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.`);
|
|
27
|
+
if (!change.length) {
|
|
28
|
+
await lifecycle.run(root, {}, store, () => {}, signal, { verb: 'build', operationToken });
|
|
29
|
+
await checkBuild();
|
|
30
|
+
}
|
|
22
31
|
if (options.dryRun) {
|
|
23
32
|
// Locally validate the real declaration/artifact without ownerTarget's
|
|
24
33
|
// account lookup. This synthetic target is validation data, not a provider.
|
|
@@ -30,22 +39,85 @@ export function ownerDeploy(declarationPath, lifecycle) {
|
|
|
30
39
|
}
|
|
31
40
|
const names = ['CLOUDFLARE_ACCOUNT_ID', 'CLOUDFLARE_API_TOKEN'];
|
|
32
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
|
+
const { env, supplied } = await deployEnvironment({ root, options, declaration, setupManifest, settings: lifecycle.settings, localOnlyKeys });
|
|
33
43
|
const { main } = await import('../modules/deploy-kit/owner-cli.mjs');
|
|
34
|
-
const
|
|
35
|
-
const {createRedaction,credentialValues} = await shared('redaction.mjs');
|
|
44
|
+
const { createRedaction, credentialValues } = await shared('redaction.mjs');
|
|
36
45
|
const redaction = createRedaction();
|
|
37
|
-
redaction.addSecrets([...credentialValues(
|
|
46
|
+
redaction.addSecrets([...credentialValues(env), env.CLOUDFLARE_ACCOUNT_ID, ...(declaration.secrets ?? []).map(item => env[item.environment]).filter(Boolean)]);
|
|
47
|
+
const claim = setupManifest?.steps?.find(item => item.type === 'owner-claim');
|
|
48
|
+
const declared = binding => (Array.isArray(declaration.secrets) ? declaration.secrets : []).find(item => item?.binding === binding);
|
|
49
|
+
const owner = (command, extra = [], values = env, more = {}) => main([command, '--declaration', declarationPath, '--environment', environment, ...extra], values, { cwd: root, localOnlyKeys, ...more });
|
|
38
50
|
// Keep confirmed creation events across deploy and the independent health call.
|
|
39
51
|
const created = [];
|
|
52
|
+
let step = 'deploy', deployed;
|
|
40
53
|
try {
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
54
|
+
if (options.remove) {
|
|
55
|
+
step = 'remove';
|
|
56
|
+
const result = await owner('remove');
|
|
57
|
+
store.report(`Removed ${result.removed.join(', ') || 'nothing (already gone)'}; read back: none of them remains.`);
|
|
58
|
+
return { removed: result.removed, deployed: false };
|
|
59
|
+
}
|
|
60
|
+
if (change.length) {
|
|
61
|
+
step = 'secrets';
|
|
62
|
+
const binding = options.setSecret ?? options.unsetSecret;
|
|
63
|
+
await owner('secret', [options.setSecret ? '--set' : '--unset', binding]);
|
|
64
|
+
store.report(`${options.setSecret ? `Set ${binding}` : `Removed ${binding}`} on this environment's Worker and recorded its new version; the next ${again} updates it.`);
|
|
65
|
+
return { binding, present: Boolean(options.setSecret), deployed: false };
|
|
66
|
+
}
|
|
67
|
+
deployed = await owner('deploy', [], env, { notice: line => store.report(line), onCreated: resource => created.push(redaction.redact(resource)) });
|
|
68
|
+
const sent = supplied.filter(name => deployed.sentSecrets.includes(name));
|
|
69
|
+
if (sent.length) store.report(`rer supplied ${sent.join(', ')} from the local setup owner and fresh generated values.`);
|
|
70
|
+
step = 'health';
|
|
71
|
+
await owner('health');
|
|
72
|
+
step = 'claim owner';
|
|
73
|
+
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 });
|
|
76
|
+
// A seed that claims its owner its own way links with the token it was given.
|
|
77
|
+
if (claimed === undefined) await linked(env[declared(claim?.tokenField ?? 'SETUP_TOKEN')?.environment]);
|
|
78
|
+
// The app claims the OWNER_EMAIL the Worker holds: the local one only when this run uploaded it.
|
|
79
|
+
const email = declared(claim?.emailField);
|
|
80
|
+
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 used and removed. Sign in by email at ${deployed.origin}.`);
|
|
82
|
+
return { deployed: true, origin: deployed.origin, ...(claimed ? { owner: claimed } : {}), dependenciesLinked: true };
|
|
44
83
|
} catch (error) {
|
|
45
|
-
|
|
46
|
-
const
|
|
47
|
-
|
|
48
|
-
throw commandFailure(`Deploy failed at ${diagnostic?.step ?? 'prepare deploy'}: ${diagnostic?.code ?? code}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.\nFirst redacted output lines:\n${diagnostic?.output ?? '(no provider output)'}\nResources created this attempt: ${created.join(', ') || 'none recorded'}.`);
|
|
84
|
+
if (error instanceof RerError) throw error;
|
|
85
|
+
const { commandFailure } = await shared('failures.mjs');
|
|
86
|
+
throw commandFailure(failureText({ error, step, created, again, retry, secretChange: Boolean(change.length && !options.remove), deployed, redact: redaction.redact }));
|
|
49
87
|
}
|
|
50
88
|
}, { declaration: declarationPath });
|
|
51
89
|
}
|
|
90
|
+
|
|
91
|
+
// The failing step, its redacted cause, what this run created and the command that continues.
|
|
92
|
+
function failureText({ error, step, created, again, retry, secretChange, deployed, redact }) {
|
|
93
|
+
const diagnostic = deployDiagnostic(error, redact);
|
|
94
|
+
const code = diagnostic?.code ?? (/^[a-zA-Z0-9_-]{1,120}$/.test(error.message) ? redact(error.message) : 'deploy-failed');
|
|
95
|
+
const at = step === 'deploy' ? diagnostic?.step ?? 'prepare deploy' : step;
|
|
96
|
+
const input = /^missing-secret-([A-Z][A-Z0-9_]*)$/.exec(code)?.[1];
|
|
97
|
+
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
|
+
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
|
+
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-claim-failed') return `The application is deployed at ${deployed?.origin}, but claiming its owner failed: ${redact(String(error.cause))}. The setup token is still on the Worker; run ${again} again to retry the claim.`;
|
|
101
|
+
const resource = diagnostic?.resource ?? (error.resource ? redact(String(error.resource)) : undefined);
|
|
102
|
+
const lines = [`Deploy failed at ${at}: ${code}${resource ? ` (${resource})` : ''}${diagnostic?.providerCodes?.length ? ` (provider ${diagnostic.providerCodes.join(', ')})` : ''}.`];
|
|
103
|
+
if (diagnostic?.output && diagnostic.output !== '(no provider output)') lines.push(`First redacted output lines:\n${diagnostic.output}`);
|
|
104
|
+
lines.push(`Resources created this attempt: ${created.join(', ') || 'none'}.`);
|
|
105
|
+
lines.push(recovery({ code, at, resource, again, retry, secretChange }));
|
|
106
|
+
return lines.join('\n');
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function recovery({ code, at, resource, again, retry, secretChange }) {
|
|
110
|
+
const remove = `${again} --remove --yes`, removing = retry === remove;
|
|
111
|
+
if (code === 'owned-resource-replaced' || code === 'resource-name-collision') return `rer refuses to change a resource it did not create or that was replaced after it did; nothing was changed. Choose another --environment name, or remove the other resource yourself.`;
|
|
112
|
+
// A lost create answer: the resource may exist, and rer cannot prove it made it.
|
|
113
|
+
if (resource && (code === 'uncertain-resource-create' || code === 'provider-transport-failed' && at.startsWith('create '))) {
|
|
114
|
+
if (resource.startsWith('worker:')) return `Possibly created: ${resource}. The answer to its first upload was lost and the live Worker does not match the build rer sent, so rer cannot prove it is its own. Deploy the same commit again to recover it, or inspect it in the Cloudflare dashboard: if it is the Worker from the interrupted run and its Durable Object data is disposable, delete it, then run ${retry} again.`;
|
|
115
|
+
return `Possibly created: ${resource}. The provider's answer to its create was lost, so rer cannot prove it made it. Inspect it in the Cloudflare dashboard: if it exists and is the empty one from the interrupted run, delete it, then run ${retry} again.${removing || resource.startsWith('queues:') ? '' : ` Or run ${remove}: it deletes it once it confirms it is empty, together with everything else this environment created.`}`;
|
|
116
|
+
}
|
|
117
|
+
if (code === 'uncertain-resource-not-empty') return `Possibly created: ${resource}, but it holds data, so it may not be the one rer's interrupted run made and rer will not delete it. Inspect it in the Cloudflare dashboard; delete it yourself only if its data is disposable, then run ${retry} again.`;
|
|
118
|
+
if (code === 'uncertain-resource-unverifiable') return `Possibly created: ${resource}. rer cannot read whether a queue holds messages, so it will not delete it. Inspect it in the Cloudflare dashboard: if it is the empty one from the interrupted run, delete it, then run ${retry} again.`;
|
|
119
|
+
if (code === 'changed-or-uncertain-migration-history') return `${resource ?? 'A D1 database'} holds a migration rer started but could not confirm, or one changed since it was applied; rer never retries or guesses a migration. Inspect it in the Cloudflare dashboard: if it is the empty one from this interrupted first deploy, delete it with ${remove} (that deletes everything this environment created), then run ${again} again. If it holds data, resolve the interrupted migration by hand first (deploy-kit README, Migration safety).`;
|
|
120
|
+
if (removing) return `Fix the cause, then run ${retry} again.`;
|
|
121
|
+
if (secretChange) return `Fix the cause, then run ${retry} again; a plain ${again} does not make this change.`;
|
|
122
|
+
return `Fix the cause, then run ${again} again: it continues with what was created. To delete what this environment created instead: ${remove}.`;
|
|
123
|
+
}
|
package/cli/door.mjs
CHANGED
|
@@ -36,7 +36,7 @@ export const doorChallengeHeader = 'x-rer-door-challenge';
|
|
|
36
36
|
export const doorIdentityPath = '/__rer/door/identity';
|
|
37
37
|
export const doorDefaults = Object.freeze({ serverLimit: 10, idleSeconds: 43200 });
|
|
38
38
|
|
|
39
|
-
export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(userInfo().homedir, '.rer/door'));
|
|
39
|
+
export const doorDirectory = (env = process.env) => resolve(env.RER_DOOR_DIR ?? join(env.HOME || userInfo().homedir, '.rer/door'));
|
|
40
40
|
export const doorOrigin = (name, port) => `http://${name}.localhost:${port}`;
|
|
41
41
|
export const doorHost = (name, port) => `${name}.localhost:${port}`;
|
|
42
42
|
|
package/cli/inspect.mjs
CHANGED
|
@@ -7,7 +7,7 @@ import { aliases, pins, requiredHooks, serverDefaults, verbs, releaseFlags } fro
|
|
|
7
7
|
import { runningVersion } from './version.mjs';
|
|
8
8
|
import { contained, readPackage } from './project.mjs';
|
|
9
9
|
import { shared } from './shared.mjs';
|
|
10
|
-
import { missing
|
|
10
|
+
import { missing } from './errors.mjs';
|
|
11
11
|
import { projectNodePin } from './node-runtime-discovery.mjs';
|
|
12
12
|
import { localSettingKeys, requiredLocalSettings } from './settings.mjs';
|
|
13
13
|
import { localAiDiagnostics } from './ai-local.mjs';
|
|
@@ -18,6 +18,7 @@ import { commandArgv, commandLine, declaredPackage, exactVersion, lockedPackage,
|
|
|
18
18
|
import { vendoredCalls } from './package-calls.mjs';
|
|
19
19
|
import { doorProtocol } from './door.mjs';
|
|
20
20
|
import { savedOrigin } from './door-mode.mjs';
|
|
21
|
+
import { probePnpm } from './pnpm-probe.mjs';
|
|
21
22
|
|
|
22
23
|
// cliVersion: the rer version of the code answering (absent for a vendored copy
|
|
23
24
|
// too old to record one). defaults: what a server start applies when the person
|
|
@@ -31,7 +32,16 @@ export async function describe(project) {
|
|
|
31
32
|
};
|
|
32
33
|
const capabilities = project.adapter?.verbs ?? {};
|
|
33
34
|
return { contractVersion: 1, ...versioned, profile: 'first-party', defaults, pins: { node: pkg.engines?.node, pnpm: pkg.packageManager?.replace(/^pnpm@/, '') },
|
|
34
|
-
verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) =>
|
|
35
|
+
verbs: Object.fromEntries(Object.entries(capabilities).map(([verb, capability]) => {
|
|
36
|
+
const adapterEffects = capability.effects;
|
|
37
|
+
const operationsEffects = project.descriptor.commands?.[verb]?.effects;
|
|
38
|
+
const effects = typeof adapterEffects === 'string' && adapterEffects.trim()
|
|
39
|
+
? adapterEffects
|
|
40
|
+
: typeof operationsEffects === 'string' && operationsEffects.trim()
|
|
41
|
+
? operationsEffects
|
|
42
|
+
: verbs[verb]?.effects ?? 'Produce releases; keygen writes a public anchor and, on success, its private key to non-terminal stdout; sign writes a detached signature and prints (but does not execute) the GitHub upload command; receipt verifies against local anchors offline.';
|
|
43
|
+
return [verb, { flags: (verbs[verb]?.flags ?? releaseFlags).filter(flag => capability.flags?.includes(flag)), effects }];
|
|
44
|
+
})),
|
|
35
45
|
aliases: Object.fromEntries(Object.entries(aliases).filter(([alias, verb]) => pkg.scripts?.[alias] === commandLine(verb, declaredPackage(pkg) !== undefined))),
|
|
36
46
|
local: { state: project.adapter?.state, settings: project.adapter?.settings ?? [], installerState: '.seed/installer', outbox: '/__dev/outbox', ...await localState(project), origin: await savedOrigin(project), ...(project.adapter?.devIdentity ? { devIdentity: true, devKey: '.rer/local/dev-key.env' } : {}) },
|
|
37
47
|
// This pin serves a checkout through the machine's front door (rer door).
|
|
@@ -137,25 +147,20 @@ async function packageClauses(root, pkg) {
|
|
|
137
147
|
export async function tools(root, store, signal) {
|
|
138
148
|
const node = await projectNodePin(root);
|
|
139
149
|
if (node && process.versions.node !== node) throw missing(`Node ${node} is required. Run it with rer, with automatic project runtime selection enabled.`);
|
|
140
|
-
const
|
|
141
|
-
|
|
142
|
-
const { cachedPnpm, downloadPnpm } = await import('./pnpm-runtime.mjs');
|
|
143
|
-
let directory = await cachedPnpm(pins.pnpm);
|
|
144
|
-
if (!directory) {
|
|
145
|
-
store.report?.(`Fetching pnpm ${pins.pnpm} for this project (PATH has ${found ? `pnpm ${found}` : 'no pnpm'}); rer keeps it in its own cache.`);
|
|
146
|
-
try { directory = await downloadPnpm(pins.pnpm, signal); }
|
|
147
|
-
catch (error) { if (signal?.aborted || error instanceof RerError) throw error; throw missing(`pnpm ${pins.pnpm} is required and could not be fetched (${error.message}). Check the connection, or install pnpm ${pins.pnpm} yourself.`); }
|
|
148
|
-
}
|
|
149
|
-
usePnpm(directory);
|
|
150
|
-
if (await probePnpm(root, store, signal) !== pins.pnpm) throw missing(`pnpm ${pins.pnpm} is required. The pnpm rer provides did not report it; install pnpm ${pins.pnpm} yourself.`);
|
|
150
|
+
const { ensureProjectPnpm } = await import('./project-pnpm.mjs');
|
|
151
|
+
await ensureProjectPnpm(root, { signal, store, report: text => store.report?.(text) });
|
|
151
152
|
}
|
|
152
|
-
const usePnpm = directory => { process.env.PATH = [dirname(process.execPath),
|
|
153
|
+
const usePnpm = directory => { process.env.PATH = [directory, dirname(process.execPath), process.env.PATH].filter(Boolean).join(delimiter); };
|
|
153
154
|
|
|
154
155
|
export async function doctor(project, store, signal) {
|
|
155
156
|
const { inspectOperations } = await import('../operations/operations.mjs');
|
|
156
157
|
const diagnostic = await inspectOperations(project.root);
|
|
157
158
|
const { localVariables } = await shared('local/environment.mjs');
|
|
158
|
-
let values = {}, pnpm, stateValid = true;
|
|
159
|
+
let values = {}, pnpm, expectedPnpm, pnpmIssue, stateValid = true;
|
|
160
|
+
if (project.profile === 'first-party') {
|
|
161
|
+
try { expectedPnpm = await (await import('./project-pnpm.mjs')).projectPnpmPin(project.root); }
|
|
162
|
+
catch (error) { pnpmIssue = error.message; }
|
|
163
|
+
}
|
|
159
164
|
if (project.profile === 'first-party') {
|
|
160
165
|
const { selectLocalState } = await shared('local/state.mjs');
|
|
161
166
|
try { await selectLocalState(project.root, project.adapter.state); } catch { stateValid = false; }
|
|
@@ -170,8 +175,8 @@ export async function doctor(project, store, signal) {
|
|
|
170
175
|
} catch {}
|
|
171
176
|
try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; }
|
|
172
177
|
// A pnpm rer already provides counts; doctor never fetches one.
|
|
173
|
-
if (pnpm !==
|
|
174
|
-
const directory = await (await import('./pnpm-runtime.mjs')).cachedPnpm(
|
|
178
|
+
if (expectedPnpm && pnpm !== expectedPnpm) {
|
|
179
|
+
const directory = await (await import('./pnpm-runtime.mjs')).cachedPnpm(expectedPnpm).catch(() => undefined);
|
|
175
180
|
if (directory) { usePnpm(directory); try { pnpm = await probePnpm(project.root, store, signal); } catch (error) { if (signal?.aborted) throw error; } }
|
|
176
181
|
}
|
|
177
182
|
}
|
|
@@ -181,27 +186,14 @@ export async function doctor(project, store, signal) {
|
|
|
181
186
|
const keys = localSettingKeys(project);
|
|
182
187
|
const inputs = project.profile === 'first-party' ? requiredLocalSettings(project, values) : [];
|
|
183
188
|
const warnings = project.profile === 'first-party' ? await cacheWarnings(project.root) : [];
|
|
184
|
-
return { dependencies, warnings, ok: !aiIssues.length && dependencies.every(d=>d.ok) && diagnostic.ok && stateValid && !inputs.length && (project.profile !== 'first-party' || (process.versions.node === expectedNode && pnpm ===
|
|
189
|
+
return { dependencies, warnings, ok: !aiIssues.length && dependencies.every(d=>d.ok) && diagnostic.ok && stateValid && !inputs.length && (project.profile !== 'first-party' || (Boolean(expectedPnpm) && process.versions.node === expectedNode && pnpm === expectedPnpm)),
|
|
185
190
|
node: { actual: process.versions.node, expected: expectedNode },
|
|
186
|
-
pnpm: project.profile === 'first-party' ? { actual: pnpm, expected:
|
|
191
|
+
pnpm: project.profile === 'first-party' ? { actual: pnpm, expected: expectedPnpm ?? null, ...(pnpmIssue ? { issue: pnpmIssue } : {}) } : undefined,
|
|
187
192
|
ai: { issues: aiIssues },
|
|
188
193
|
missingInputs: [...new Set([...diagnostic.missingEnvironment, ...inputs])],
|
|
189
194
|
sources: project.descriptor.environmentSources ?? {}, state: { path: project.adapter?.state, safe: stateValid }, scope: 'local-inputs-only' };
|
|
190
195
|
}
|
|
191
196
|
|
|
192
|
-
// Diagnostics and execution use the same identity-checked process custody.
|
|
193
|
-
// Exit starts cleanup even if a descendant still holds the output pipes.
|
|
194
|
-
async function probePnpm(root, store, signal) {
|
|
195
|
-
const probeSignal = AbortSignal.any([signal, AbortSignal.timeout(10000)].filter(Boolean));
|
|
196
|
-
const child = store.spawn('Checking pinned pnpm', 'pnpm', ['--version'], { cwd: root, signal: probeSignal });
|
|
197
|
-
let version = '';
|
|
198
|
-
child.stdout.on('data', chunk => { version = (version + chunk).slice(-1024); });
|
|
199
|
-
const code = await new Promise(resolve => { child.once('error', () => resolve(null)); child.once('exit', code => resolve(code)); });
|
|
200
|
-
await store.stop(child);
|
|
201
|
-
if (probeSignal.aborted || code !== 0) throw missing('The pinned pnpm version probe failed. Select the documented tool.');
|
|
202
|
-
return version.trim();
|
|
203
|
-
}
|
|
204
|
-
|
|
205
197
|
// SC-05: a seed declaring devIdentity loads the dev layer only from a local-only
|
|
206
198
|
// entry that its dev configuration names, never from the deployed main, lets
|
|
207
199
|
// rer build scan its output, and keeps one end-to-end email-link test.
|
package/cli/lifecycle.mjs
CHANGED
|
@@ -19,7 +19,7 @@ import { announceDoorRoute, routeRunning, verifyDoorFor } from './door-mode.mjs'
|
|
|
19
19
|
export async function runCommand(project, options, out) {
|
|
20
20
|
if(project.profile==='first-party'&&['dev','preview'].includes(options.verb))return (await import('./managed-server.mjs')).managedServer(project,options,out);
|
|
21
21
|
const cancellation = new AbortController(), signal = cancellation.signal;
|
|
22
|
-
let termination, operation, store, temporary, proxy;
|
|
22
|
+
let termination, operation, store, temporary, proxy, failed = false;
|
|
23
23
|
const handlers = new Map(['SIGINT', 'SIGTERM'].map(name => [name, () => { if (!termination) { termination = name; cancellation.abort(name); } }]));
|
|
24
24
|
for (const [name, handler] of handlers) process.on(name, handler);
|
|
25
25
|
try {
|
|
@@ -85,8 +85,10 @@ export async function runCommand(project, options, out) {
|
|
|
85
85
|
if (['export', 'import'].includes(options.verb)) return await transfer(project, options, signal, { store, operationToken: operation.token });
|
|
86
86
|
if (options.verb === 'deploy') {
|
|
87
87
|
if (typeof adapter.deploy !== 'function') throw refuse('Owner-account deploy declaration is missing.');
|
|
88
|
-
const
|
|
89
|
-
|
|
88
|
+
const link = async (origin, appSetupToken) => linkDependencies({project,input:project.setupSeed?await savedSetupAnswers(project,project.setupSeed.model):undefined,origin,store,signal,appSetupToken});
|
|
89
|
+
const deployed = await adapter.deploy({ root: project.root, options, store, signal, operationToken: operation.token, localOnlyKeys: adapter.localOnlyKeys ?? [], checkBuild: () => refuseDevLayerOutput(project.root, adapter.devIdentity), setupManifest: project.setupManifest, linkDependencies: link });
|
|
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']);
|
|
90
92
|
return deployed;
|
|
91
93
|
}
|
|
92
94
|
// Setup writes the checkout's dev key; its temporary app and the CLI's own
|
|
@@ -135,10 +137,18 @@ export async function runCommand(project, options, out) {
|
|
|
135
137
|
} else if (options.verb !== 'build') throw refuse('The requested first-party capability is unavailable.');
|
|
136
138
|
return {};
|
|
137
139
|
} catch (error) {
|
|
140
|
+
failed = true;
|
|
138
141
|
if (error instanceof RerError) throw error;
|
|
139
142
|
throw await lifecycleError(error, store);
|
|
140
143
|
} finally {
|
|
141
|
-
|
|
144
|
+
// Every closer runs: one that fails must not leave the job-queue policy socket
|
|
145
|
+
// listening (the process would never exit) or the local operation held, and
|
|
146
|
+
// must not turn a finished command into a failure or hide the real one.
|
|
147
|
+
const unclosed = [];
|
|
148
|
+
for (const close of [() => store?.closeCommands(), () => proxy?.close(), () => operation?.release()]) {
|
|
149
|
+
try { await close(); } catch (error) { unclosed.push(store?.redact?.(String(error?.message)) ?? 'unknown cause'); }
|
|
150
|
+
}
|
|
151
|
+
if (unclosed.length && !failed) out.progress(`Local cleanup could not confirm that every helper process stopped (${unclosed.join('; ')}). The ${options.verb} itself finished.\n`);
|
|
142
152
|
if (temporary) await rm(temporary, { recursive: true, force: true });
|
|
143
153
|
for (const [name, handler] of handlers) process.removeListener(name, handler);
|
|
144
154
|
if (termination) throw new RerError('cancelled', `Stopped by ${termination} after owned children were stopped.`, termination === 'SIGINT' ? 130 : 143);
|
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']);
|
|
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']);
|
|
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.
|
|
@@ -72,6 +72,8 @@ export function parse(argv, cwd = process.cwd(), { independent = false, deferTai
|
|
|
72
72
|
if (options.target !== undefined && !['local', 'remote'].includes(options.target)) throw invalid('Target must be local or remote.');
|
|
73
73
|
if (options.profile !== undefined && options.profile !== 'first-party') throw invalid('The supported explicit profile is first-party.');
|
|
74
74
|
if (options.environment !== undefined && !/^[a-z][a-z0-9-]{0,39}$/.test(options.environment)) throw invalid('Provide a valid deployment environment name.');
|
|
75
|
+
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.');
|
|
76
|
+
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.');
|
|
75
77
|
if (options.origin !== undefined) {
|
|
76
78
|
let url; try { url = new URL(options.origin); } catch { throw invalid('Provide a valid credential-free origin.'); }
|
|
77
79
|
if (!['http:', 'https:'].includes(url.protocol) || url.username || url.password || url.search || url.hash || url.pathname !== '/') throw invalid('Provide a credential-free HTTP origin without a path.');
|