@indigoai-us/hq-cli 5.289.0 → 5.291.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +61 -0
- package/assets/scaffold/core/scripts/handoff-finalize.sh +670 -0
- package/assets/scaffold/core/scripts/handoff-open-steps.sh +81 -0
- package/assets/scaffold/core/scripts/handoff-post.sh +187 -0
- package/assets/scaffold/core/scripts/hook-lib.sh +345 -50
- package/assets/scaffold/core/scripts/hq-session.sh +182 -21
- package/assets/scaffold/core/scripts/jobs-schedule-parse.sh +310 -0
- package/assets/scaffold/core/scripts/jobs-validate.sh +451 -0
- package/assets/scaffold/core/scripts/lib/session-id.sh +3 -2
- package/assets/scaffold/core/scripts/lib/work-mesh-enqueue.sh +2 -1
- package/assets/scaffold/core/scripts/qmd-reindex-bg.sh +40 -0
- package/dist/command-catalog.generated.d.ts +110 -0
- package/dist/command-catalog.generated.js +136 -0
- package/dist/commands/cloud.d.ts +26 -14
- package/dist/commands/cloud.js +207 -56
- package/dist/commands/core-handoff.d.ts +22 -0
- package/dist/commands/core-handoff.js +1240 -0
- package/dist/commands/core-jobs-native.d.ts +8 -0
- package/dist/commands/core-jobs-native.js +624 -0
- package/dist/commands/core-native-jobs-flag.d.ts +4 -0
- package/dist/commands/core-native-jobs-flag.js +7 -0
- package/dist/commands/core-ontology.d.ts +6 -1
- package/dist/commands/core-ontology.js +42 -7
- package/dist/commands/core-policy.js +24 -5
- package/dist/commands/core.js +15 -1
- package/dist/commands/integrations-core.js +7 -0
- package/dist/commands/lanes.js +111 -35
- package/dist/commands/members.js +5 -0
- package/dist/commands/mesh.js +46 -25
- package/dist/commands/personal-targeted-push-flag.d.ts +4 -0
- package/dist/commands/personal-targeted-push-flag.js +7 -0
- package/dist/commands/personal-targeted-push-paths.d.ts +3 -0
- package/dist/commands/personal-targeted-push-paths.js +67 -0
- package/dist/commands/secrets.js +9 -0
- package/dist/commands/skill.d.ts +3 -0
- package/dist/commands/skill.js +28 -0
- package/dist/fast-core.js +1 -0
- package/dist/lib/billing/free-month.d.ts +18 -0
- package/dist/lib/billing/free-month.js +31 -0
- package/dist/lib/billing/plan-limit-refusals.d.ts +69 -0
- package/dist/lib/billing/plan-limit-refusals.js +187 -0
- package/dist/lib/billing/plan-lock.d.ts +2 -6
- package/dist/lib/billing/plan-lock.js +5 -16
- package/dist/lib/checkpoint-ontology/entity-file.d.ts +21 -0
- package/dist/lib/checkpoint-ontology/entity-file.js +100 -0
- package/dist/lib/checkpoint-ontology/merge-copies.d.ts +34 -2
- package/dist/lib/checkpoint-ontology/merge-copies.js +541 -27
- package/dist/lib/core-utils/secret-patterns.d.ts +3 -1
- package/dist/lib/core-utils/secret-patterns.js +3 -2
- package/dist/lib/flag-registry-worker-source.generated.d.ts +1 -1
- package/dist/lib/flag-registry-worker-source.generated.js +1 -1
- package/dist/lib/lanes/autopilot.d.ts +69 -0
- package/dist/lib/lanes/autopilot.js +418 -0
- package/dist/lib/lanes/mesh-events.js +2 -0
- package/dist/lib/lanes/store.d.ts +4 -2
- package/dist/lib/lanes/store.js +18 -9
- package/dist/lib/mesh/api.js +4 -0
- package/dist/lib/mesh/live/daemon/state.d.ts +7 -1
- package/dist/lib/mesh/live/daemon/state.js +34 -2
- package/dist/main.js +23 -12
- package/dist/utils/plan-gate-error.d.ts +24 -0
- package/dist/utils/plan-gate-error.js +219 -9
- package/dist/utils/self-update.d.ts +3 -0
- package/dist/utils/self-update.js +232 -1
- package/package.json +2 -2
package/dist/commands/secrets.js
CHANGED
|
@@ -5,6 +5,7 @@ import { cognitoCachePrincipalUid, ensureCognitoToken } from "../utils/cognito-s
|
|
|
5
5
|
import { isAuthError } from "../utils/auth-error.js";
|
|
6
6
|
import { DEFAULT_SECRETS_CACHE_TTL_MS, hasCacheEntry, readCache, writeCache, removeCacheEntry, clearAllCache, } from "../utils/secrets-cache.js";
|
|
7
7
|
import { computeSha256 } from "../utils/integrity.js";
|
|
8
|
+
import { isPlanGateError } from "../utils/plan-gate-error.js";
|
|
8
9
|
import { SECRET_NAME_PATTERN, GROUP_ID_PATTERN, EMAIL_PATTERN } from "./_patterns.js";
|
|
9
10
|
import { describeSecretsScope, formatSecretSaved, formatSecretsListEmpty, formatSecretsListHeader, } from "./secrets-scope.js";
|
|
10
11
|
import { vaultApiFetch, getCompanyUid, getEntityUid, looksLikeCompanyUid, } from "../utils/vault-api.js";
|
|
@@ -988,6 +989,11 @@ export function registerSecretsCommand(program) {
|
|
|
988
989
|
}
|
|
989
990
|
}
|
|
990
991
|
catch (err) {
|
|
992
|
+
// A plan-limit refusal goes to main.ts's shared renderer, which prints
|
|
993
|
+
// the reason, usage, fix options and upgrade link, offers the upgrade
|
|
994
|
+
// interactively, and exits non-zero.
|
|
995
|
+
if (isPlanGateError(err))
|
|
996
|
+
throw err;
|
|
991
997
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
992
998
|
process.exit(1);
|
|
993
999
|
}
|
|
@@ -1650,6 +1656,9 @@ export function registerSecretsCommand(program) {
|
|
|
1650
1656
|
console.log(chalk.dim(" One-time use — link is invalidated after first submission."));
|
|
1651
1657
|
}
|
|
1652
1658
|
catch (err) {
|
|
1659
|
+
// Same shared plan-limit renderer as `secrets set` (see main.ts).
|
|
1660
|
+
if (isPlanGateError(err))
|
|
1661
|
+
throw err;
|
|
1653
1662
|
console.error(chalk.red("Error:"), err instanceof Error ? err.message : String(err));
|
|
1654
1663
|
process.exit(1);
|
|
1655
1664
|
}
|
package/dist/commands/skill.d.ts
CHANGED
|
@@ -18,6 +18,7 @@ import { Command } from "commander";
|
|
|
18
18
|
import { ensureCognitoToken } from "../utils/cognito-session.js";
|
|
19
19
|
import { vaultApiFetch, getCompanyUid } from "../utils/vault-api.js";
|
|
20
20
|
import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
|
|
21
|
+
import { type PlanLimitRefusalSummary } from "../lib/billing/plan-limit-refusals.js";
|
|
21
22
|
export declare const SKILL_UID_PATTERN: RegExp;
|
|
22
23
|
export declare const SKILL_SLUG_PATTERN: RegExp;
|
|
23
24
|
interface SkillSyncInput {
|
|
@@ -30,6 +31,8 @@ interface SkillSyncResult {
|
|
|
30
31
|
filesUploaded: number;
|
|
31
32
|
filesSkipped: number;
|
|
32
33
|
aborted: boolean;
|
|
34
|
+
/** Set when a plan limit refused the upload; absent or null otherwise. */
|
|
35
|
+
planLimit?: PlanLimitRefusalSummary | null;
|
|
33
36
|
}
|
|
34
37
|
export declare function parseSkillUid(markdown: string): string | undefined;
|
|
35
38
|
export declare function readActiveCompanySlug(hqRoot: string): string | undefined;
|
package/dist/commands/skill.js
CHANGED
|
@@ -27,25 +27,47 @@ import { surfaceCompanySkill } from "../lib/company-skill-wrapper.js";
|
|
|
27
27
|
import { AuthError } from "../utils/auth-error.js";
|
|
28
28
|
import { redactErrorText } from "../utils/redact-error-text.js";
|
|
29
29
|
import { stampVaultAccessDenied, vaultAccessDeniedDiagnostics, } from "../utils/vault-access-denied-error.js";
|
|
30
|
+
import { PlanGateError } from "../utils/plan-gate-error.js";
|
|
31
|
+
import { createPlanLimitRefusalTracker, trackingProgressRecorder, } from "../lib/billing/plan-limit-refusals.js";
|
|
30
32
|
export const SKILL_UID_PATTERN = /^skl_[A-Za-z0-9]+$/;
|
|
31
33
|
export const SKILL_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
32
34
|
const COMPANY_SLUG_PATTERN = /^[a-z0-9][a-z0-9-]*$/;
|
|
33
35
|
const FRONTMATTER_RE = /^---\r?\n([\s\S]*?)\r?\n---(?:\r?\n|$)/;
|
|
34
36
|
const SHA256_HEX_PATTERN = /^[0-9a-f]{64}$/;
|
|
35
37
|
async function defaultSyncFile(input) {
|
|
38
|
+
// The runner reports a plan-limit refusal as an event, not a throw, and still
|
|
39
|
+
// returns normally. Track it so the command cannot claim "Sync: complete".
|
|
40
|
+
const tracker = createPlanLimitRefusalTracker({ companySlug: input.companySlug });
|
|
36
41
|
const result = await share({
|
|
37
42
|
paths: [input.filePath],
|
|
38
43
|
company: input.companySlug,
|
|
39
44
|
hqRoot: input.hqRoot,
|
|
40
45
|
vaultConfig: buildVaultConfig(input.token),
|
|
41
46
|
onConflict: "abort",
|
|
47
|
+
progressRecorder: trackingProgressRecorder(tracker, input.companySlug),
|
|
42
48
|
});
|
|
43
49
|
return {
|
|
44
50
|
filesUploaded: result.filesUploaded,
|
|
45
51
|
filesSkipped: result.filesSkipped,
|
|
46
52
|
aborted: result.aborted,
|
|
53
|
+
planLimit: tracker.summary(),
|
|
47
54
|
};
|
|
48
55
|
}
|
|
56
|
+
/**
|
|
57
|
+
* The upload of a stamped skill file was refused by a plan limit. Registration
|
|
58
|
+
* and the local stamp already succeeded, so say that on stderr, then return a
|
|
59
|
+
* typed plan gate for main.ts to render (reason and upgrade link) and exit
|
|
60
|
+
* non-zero. Never "Skill ready … Sync: complete".
|
|
61
|
+
*/
|
|
62
|
+
function skillUploadRefused(skillUid, filePath, companySlug, refused) {
|
|
63
|
+
console.warn(chalk.yellow(`⚠ Skill ${skillUid} is stamped locally at '${filePath}', but the upload was refused by your plan limit.`));
|
|
64
|
+
return new PlanGateError("PLAN_LIMIT_EXCEEDED", {
|
|
65
|
+
reason: `${refused.message ?? "New files are paused by your plan limit."} ` +
|
|
66
|
+
"The skill file was not uploaded. Once the limit is sorted, upload it with: " +
|
|
67
|
+
`hq sync push --company ${companySlug} '${filePath}'`,
|
|
68
|
+
...(refused.upgradeUrl ? { upgradeUrl: refused.upgradeUrl } : {}),
|
|
69
|
+
});
|
|
70
|
+
}
|
|
49
71
|
export function parseSkillUid(markdown) {
|
|
50
72
|
const match = markdown.match(FRONTMATTER_RE);
|
|
51
73
|
if (!match)
|
|
@@ -575,6 +597,9 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
575
597
|
// Sentry rather than filing a crash on its first occurrence.
|
|
576
598
|
throw localSkillError(`Skill ${registered.skillUid} is stamped locally at '${filePath}', but sync aborted because the remote file conflicts.`);
|
|
577
599
|
}
|
|
600
|
+
if (syncResult.planLimit) {
|
|
601
|
+
throw skillUploadRefused(registered.skillUid, filePath, companySlug, syncResult.planLimit);
|
|
602
|
+
}
|
|
578
603
|
}
|
|
579
604
|
console.log(chalk.green(`Skill ready: ${registered.skillUid}`));
|
|
580
605
|
console.log(` File: ${filePath}`);
|
|
@@ -739,6 +764,9 @@ export function registerSkillCommand(program, deps = {}) {
|
|
|
739
764
|
if (syncResult.aborted) {
|
|
740
765
|
throw localSkillError(`Skill ${skillUid} is stamped locally at '${canonicalPath}', but sync aborted because the remote file conflicts.`);
|
|
741
766
|
}
|
|
767
|
+
if (syncResult.planLimit) {
|
|
768
|
+
throw skillUploadRefused(skillUid, canonicalPath, companySlug, syncResult.planLimit);
|
|
769
|
+
}
|
|
742
770
|
}
|
|
743
771
|
console.log(chalk.green(`Skill promoted: ${skillUid}`));
|
|
744
772
|
console.log(` File: ${canonicalPath}`);
|
package/dist/fast-core.js
CHANGED
|
@@ -54,6 +54,7 @@ export async function runFastCore(argv) {
|
|
|
54
54
|
args: parsed.args,
|
|
55
55
|
cwd: process.cwd(),
|
|
56
56
|
hqRoot,
|
|
57
|
+
env: parsed.entry.name === "hq-session" ? { HQ_HQ_SESSION_NO_CLI: "1" } : undefined,
|
|
57
58
|
});
|
|
58
59
|
// Propagate verbatim: these scripts use their exit code as their interface.
|
|
59
60
|
process.exit(code);
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The free-month offer hq-pro attaches to Starter plan-limit payloads (the
|
|
3
|
+
* usage-limits nag and the hard-stop 402 body). Kept in a leaf module with no
|
|
4
|
+
* imports so the plan-gate error decoder can reuse the one parser without
|
|
5
|
+
* pulling in the HTTP layer that itself imports the decoder.
|
|
6
|
+
*/
|
|
7
|
+
export interface PlanLimitFreeMonth {
|
|
8
|
+
eligible: boolean;
|
|
9
|
+
trialDays?: number;
|
|
10
|
+
url?: string;
|
|
11
|
+
}
|
|
12
|
+
/**
|
|
13
|
+
* Parse `freeMonth` off a server payload. Anything but an explicit
|
|
14
|
+
* `eligible: true` is "no offer": an absent or malformed field never invents
|
|
15
|
+
* one.
|
|
16
|
+
*/
|
|
17
|
+
export declare function parseFreeMonth(value: unknown): PlanLimitFreeMonth | undefined;
|
|
18
|
+
//# sourceMappingURL=free-month.d.ts.map
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The free-month offer hq-pro attaches to Starter plan-limit payloads (the
|
|
3
|
+
* usage-limits nag and the hard-stop 402 body). Kept in a leaf module with no
|
|
4
|
+
* imports so the plan-gate error decoder can reuse the one parser without
|
|
5
|
+
* pulling in the HTTP layer that itself imports the decoder.
|
|
6
|
+
*/
|
|
7
|
+
/**
|
|
8
|
+
* Parse `freeMonth` off a server payload. Anything but an explicit
|
|
9
|
+
* `eligible: true` is "no offer": an absent or malformed field never invents
|
|
10
|
+
* one.
|
|
11
|
+
*/
|
|
12
|
+
export function parseFreeMonth(value) {
|
|
13
|
+
if (value === null || typeof value !== "object" || Array.isArray(value)) {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
const rec = value;
|
|
17
|
+
if (rec.eligible !== true)
|
|
18
|
+
return undefined;
|
|
19
|
+
const url = typeof rec.url === "string" && rec.url.trim().length > 0
|
|
20
|
+
? rec.url.trim()
|
|
21
|
+
: undefined;
|
|
22
|
+
const trialDays = typeof rec.trialDays === "number" && Number.isFinite(rec.trialDays)
|
|
23
|
+
? rec.trialDays
|
|
24
|
+
: undefined;
|
|
25
|
+
return {
|
|
26
|
+
eligible: true,
|
|
27
|
+
...(trialDays !== undefined ? { trialDays } : {}),
|
|
28
|
+
...(url ? { url } : {}),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
//# sourceMappingURL=free-month.js.map
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the files a sync push was refused by a plan limit, from the surfaces
|
|
3
|
+
* the pinned `@indigoai-us/hq-cloud` runner already has, so `hq sync push`,
|
|
4
|
+
* `hq sync now` and `hq skill create` never print a success line over a
|
|
5
|
+
* refusal.
|
|
6
|
+
*
|
|
7
|
+
* The runner reports a refusal two ways today:
|
|
8
|
+
*
|
|
9
|
+
* 1. A `{ type: "plan-limit", upgradeUrl }` event, emitted once per push pass
|
|
10
|
+
* when a presign row carried a code it recognises. It names no path.
|
|
11
|
+
* 2. A per-file `{ type: "error", path, message }` event for a 402 it does not
|
|
12
|
+
* recognise (hq-pro's hard-stop body). The message is hq-cloud's
|
|
13
|
+
* `describeError` string, which carries `http=402` and, when the server
|
|
14
|
+
* sent one, `code=…`.
|
|
15
|
+
*
|
|
16
|
+
* `@indigoai-us/hq-cloud` 6.18.17 (US-012) recognises the hard-stop body,
|
|
17
|
+
* names each refused path on the `plan-limit` event (`paths`, `count`) and
|
|
18
|
+
* stops re-sending paused keys. The `path`/`paths`/`count` reads below consume
|
|
19
|
+
* that event; the `error`-message parsing stays for runners older than
|
|
20
|
+
* 6.18.17.
|
|
21
|
+
*/
|
|
22
|
+
import { type SyncProgressEvent } from "@indigoai-us/hq-cloud";
|
|
23
|
+
/** What one push leg was refused. `null` from the tracker means nothing was. */
|
|
24
|
+
export interface PlanLimitRefusalSummary {
|
|
25
|
+
/**
|
|
26
|
+
* Refused files the runner reported. A `plan-limit` event with no path or
|
|
27
|
+
* count still means at least one file, so this is never 0.
|
|
28
|
+
*/
|
|
29
|
+
count: number;
|
|
30
|
+
/** Vault-relative paths the runner named, de-duplicated, in report order. */
|
|
31
|
+
paths: string[];
|
|
32
|
+
/** Server sentence from a refused file's error, when one was carried. */
|
|
33
|
+
message?: string;
|
|
34
|
+
/** Server-selected (or company billing fallback) upgrade link. */
|
|
35
|
+
upgradeUrl?: string;
|
|
36
|
+
}
|
|
37
|
+
export interface PlanLimitRefusalTracker {
|
|
38
|
+
observe(event: SyncProgressEvent): void;
|
|
39
|
+
summary(): PlanLimitRefusalSummary | null;
|
|
40
|
+
}
|
|
41
|
+
/** True when a runner `error` message is a plan-limit (402) refusal. */
|
|
42
|
+
export declare function isPlanLimitErrorMessage(message: unknown): boolean;
|
|
43
|
+
/**
|
|
44
|
+
* @param opts.companySlug the push target's slug, used for the company billing
|
|
45
|
+
* link when the runner did not carry one. Omit for a personal-vault leg.
|
|
46
|
+
*/
|
|
47
|
+
export declare function createPlanLimitRefusalTracker(opts?: {
|
|
48
|
+
companySlug?: string;
|
|
49
|
+
}): PlanLimitRefusalTracker;
|
|
50
|
+
/**
|
|
51
|
+
* The `progressRecorder` for a share() call: feeds the tracker and still writes
|
|
52
|
+
* the shared progress snapshot, exactly as share()'s default recorder does.
|
|
53
|
+
* Passing the recorder rather than `onEvent` keeps share()'s own console output
|
|
54
|
+
* unchanged. `company` is the snapshot label: the target slug, or null for the
|
|
55
|
+
* personal vault.
|
|
56
|
+
*/
|
|
57
|
+
export declare function trackingProgressRecorder(tracker: PlanLimitRefusalTracker, company: string | null): (event: SyncProgressEvent) => void;
|
|
58
|
+
/** Add a later leg's refusals to an earlier total. */
|
|
59
|
+
export declare function mergePlanLimitRefusals(a: PlanLimitRefusalSummary | null | undefined, b: PlanLimitRefusalSummary | null | undefined): PlanLimitRefusalSummary | null;
|
|
60
|
+
/**
|
|
61
|
+
* Plain lines describing a refusal: what was not uploaded, why, and the
|
|
62
|
+
* upgrade link. Colour and the leading status line are the caller's.
|
|
63
|
+
*/
|
|
64
|
+
export declare function renderPlanLimitRefusalLines(summary: PlanLimitRefusalSummary, indent?: string): string[];
|
|
65
|
+
/** "1 new file" / "3 new files", or "new files" when only a notice said so. */
|
|
66
|
+
export declare function describeRefusedCount(summary: PlanLimitRefusalSummary): string;
|
|
67
|
+
/** {@link describeRefusedCount} with its verb: "1 new file was", "new files were". */
|
|
68
|
+
export declare function describeRefusedFiles(summary: PlanLimitRefusalSummary): string;
|
|
69
|
+
//# sourceMappingURL=plan-limit-refusals.d.ts.map
|
|
@@ -0,0 +1,187 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Collect the files a sync push was refused by a plan limit, from the surfaces
|
|
3
|
+
* the pinned `@indigoai-us/hq-cloud` runner already has, so `hq sync push`,
|
|
4
|
+
* `hq sync now` and `hq skill create` never print a success line over a
|
|
5
|
+
* refusal.
|
|
6
|
+
*
|
|
7
|
+
* The runner reports a refusal two ways today:
|
|
8
|
+
*
|
|
9
|
+
* 1. A `{ type: "plan-limit", upgradeUrl }` event, emitted once per push pass
|
|
10
|
+
* when a presign row carried a code it recognises. It names no path.
|
|
11
|
+
* 2. A per-file `{ type: "error", path, message }` event for a 402 it does not
|
|
12
|
+
* recognise (hq-pro's hard-stop body). The message is hq-cloud's
|
|
13
|
+
* `describeError` string, which carries `http=402` and, when the server
|
|
14
|
+
* sent one, `code=…`.
|
|
15
|
+
*
|
|
16
|
+
* `@indigoai-us/hq-cloud` 6.18.17 (US-012) recognises the hard-stop body,
|
|
17
|
+
* names each refused path on the `plan-limit` event (`paths`, `count`) and
|
|
18
|
+
* stops re-sending paused keys. The `path`/`paths`/`count` reads below consume
|
|
19
|
+
* that event; the `error`-message parsing stays for runners older than
|
|
20
|
+
* 6.18.17.
|
|
21
|
+
*/
|
|
22
|
+
import { createSyncProgressRecorder, } from "@indigoai-us/hq-cloud";
|
|
23
|
+
import { planLimitUpgradeFallbackUrl } from "./plan-limit-upgrade-url.js";
|
|
24
|
+
/** Upgrade destination when neither the event nor the error named one. */
|
|
25
|
+
const FALLBACK_UPGRADE_URL = "https://hq.computer/billing";
|
|
26
|
+
const PLAN_LIMIT_CODE_TOKEN = /\bcode=(?:PLAN_LIMIT_EXCEEDED|PLAN_LIMIT_REACHED|plan_limit_reached)\b/;
|
|
27
|
+
const HTTP_402_TOKEN = /\bhttp=402\b/;
|
|
28
|
+
const DIAGNOSTIC_TOKEN = /^(?:[A-Za-z]+Error|code=\S+|path=\S+|http=\d+|syscall=\S+|cause=\S+|causeHttp=\d+|host=\S+)$/;
|
|
29
|
+
/** True when a runner `error` message is a plan-limit (402) refusal. */
|
|
30
|
+
export function isPlanLimitErrorMessage(message) {
|
|
31
|
+
return (typeof message === "string" &&
|
|
32
|
+
(PLAN_LIMIT_CODE_TOKEN.test(message) || HTTP_402_TOKEN.test(message)));
|
|
33
|
+
}
|
|
34
|
+
/**
|
|
35
|
+
* The human sentence at the end of a `describeError` string, after its
|
|
36
|
+
* `Name code=… path=… http=…` diagnostic prefix. Everything up to the `http=`
|
|
37
|
+
* token is dropped first, because a `path=` value may itself contain spaces.
|
|
38
|
+
* Undefined when nothing but diagnostics (or a machine token) is left.
|
|
39
|
+
*/
|
|
40
|
+
function serverSentence(message) {
|
|
41
|
+
const http = /\bhttp=\d+\s+/.exec(message);
|
|
42
|
+
const rest = http ? message.slice(http.index + http[0].length) : message;
|
|
43
|
+
const words = rest.trim().split(/\s+/);
|
|
44
|
+
let index = 0;
|
|
45
|
+
while (index < words.length && DIAGNOSTIC_TOKEN.test(words[index]))
|
|
46
|
+
index++;
|
|
47
|
+
const sentence = words.slice(index).join(" ").trim();
|
|
48
|
+
if (!sentence || sentence === "plan_limit_reached")
|
|
49
|
+
return undefined;
|
|
50
|
+
return sentence;
|
|
51
|
+
}
|
|
52
|
+
function safeHttpsUrl(value) {
|
|
53
|
+
if (typeof value !== "string" || value.trim() !== value)
|
|
54
|
+
return undefined;
|
|
55
|
+
try {
|
|
56
|
+
const url = new URL(value);
|
|
57
|
+
return url.protocol === "https:" && !url.username && !url.password
|
|
58
|
+
? value
|
|
59
|
+
: undefined;
|
|
60
|
+
}
|
|
61
|
+
catch {
|
|
62
|
+
return undefined;
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* @param opts.companySlug the push target's slug, used for the company billing
|
|
67
|
+
* link when the runner did not carry one. Omit for a personal-vault leg.
|
|
68
|
+
*/
|
|
69
|
+
export function createPlanLimitRefusalTracker(opts = {}) {
|
|
70
|
+
const paths = [];
|
|
71
|
+
let noticeSeen = false;
|
|
72
|
+
let reportedCount = 0;
|
|
73
|
+
let message;
|
|
74
|
+
let upgradeUrl;
|
|
75
|
+
const addPath = (value) => {
|
|
76
|
+
if (typeof value === "string" && value && !paths.includes(value)) {
|
|
77
|
+
paths.push(value);
|
|
78
|
+
}
|
|
79
|
+
};
|
|
80
|
+
return {
|
|
81
|
+
observe(event) {
|
|
82
|
+
const record = event;
|
|
83
|
+
if (event.type === "plan-limit") {
|
|
84
|
+
noticeSeen = true;
|
|
85
|
+
upgradeUrl ??= safeHttpsUrl(record.upgradeUrl);
|
|
86
|
+
addPath(record.path);
|
|
87
|
+
if (Array.isArray(record.paths))
|
|
88
|
+
record.paths.forEach(addPath);
|
|
89
|
+
if (typeof record.count === "number" && Number.isFinite(record.count)) {
|
|
90
|
+
reportedCount += Math.max(0, Math.trunc(record.count));
|
|
91
|
+
}
|
|
92
|
+
return;
|
|
93
|
+
}
|
|
94
|
+
if (event.type === "error" &&
|
|
95
|
+
record.diagnostic !== true &&
|
|
96
|
+
isPlanLimitErrorMessage(record.message)) {
|
|
97
|
+
noticeSeen = true;
|
|
98
|
+
addPath(record.path);
|
|
99
|
+
message ??= serverSentence(record.message);
|
|
100
|
+
}
|
|
101
|
+
},
|
|
102
|
+
summary() {
|
|
103
|
+
if (!noticeSeen)
|
|
104
|
+
return null;
|
|
105
|
+
const fallbackUrl = opts.companySlug
|
|
106
|
+
? planLimitUpgradeFallbackUrl(opts.companySlug, FALLBACK_UPGRADE_URL)
|
|
107
|
+
: undefined;
|
|
108
|
+
const url = upgradeUrl ?? fallbackUrl;
|
|
109
|
+
return {
|
|
110
|
+
count: Math.max(1, paths.length, reportedCount),
|
|
111
|
+
paths: [...paths],
|
|
112
|
+
...(message ? { message } : {}),
|
|
113
|
+
...(url ? { upgradeUrl: url } : {}),
|
|
114
|
+
};
|
|
115
|
+
},
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
/**
|
|
119
|
+
* The `progressRecorder` for a share() call: feeds the tracker and still writes
|
|
120
|
+
* the shared progress snapshot, exactly as share()'s default recorder does.
|
|
121
|
+
* Passing the recorder rather than `onEvent` keeps share()'s own console output
|
|
122
|
+
* unchanged. `company` is the snapshot label: the target slug, or null for the
|
|
123
|
+
* personal vault.
|
|
124
|
+
*/
|
|
125
|
+
export function trackingProgressRecorder(tracker, company) {
|
|
126
|
+
const record = createSyncProgressRecorder({ company, phase: "push" });
|
|
127
|
+
return (event) => {
|
|
128
|
+
tracker.observe(event);
|
|
129
|
+
record(event);
|
|
130
|
+
};
|
|
131
|
+
}
|
|
132
|
+
/** Add a later leg's refusals to an earlier total. */
|
|
133
|
+
export function mergePlanLimitRefusals(a, b) {
|
|
134
|
+
if (!a)
|
|
135
|
+
return b ?? null;
|
|
136
|
+
if (!b)
|
|
137
|
+
return a;
|
|
138
|
+
const paths = [...a.paths];
|
|
139
|
+
for (const p of b.paths)
|
|
140
|
+
if (!paths.includes(p))
|
|
141
|
+
paths.push(p);
|
|
142
|
+
const message = a.message ?? b.message;
|
|
143
|
+
const upgradeUrl = a.upgradeUrl ?? b.upgradeUrl;
|
|
144
|
+
return {
|
|
145
|
+
count: a.count + b.count,
|
|
146
|
+
paths,
|
|
147
|
+
...(message ? { message } : {}),
|
|
148
|
+
...(upgradeUrl ? { upgradeUrl } : {}),
|
|
149
|
+
};
|
|
150
|
+
}
|
|
151
|
+
/** How many named paths to print before summarising the rest. */
|
|
152
|
+
const MAX_LISTED_PATHS = 20;
|
|
153
|
+
/**
|
|
154
|
+
* Plain lines describing a refusal: what was not uploaded, why, and the
|
|
155
|
+
* upgrade link. Colour and the leading status line are the caller's.
|
|
156
|
+
*/
|
|
157
|
+
export function renderPlanLimitRefusalLines(summary, indent = " ") {
|
|
158
|
+
const lines = [];
|
|
159
|
+
lines.push(`${indent}${summary.message ?? "New files are paused by your plan limit."}`);
|
|
160
|
+
if (summary.paths.length > 0) {
|
|
161
|
+
lines.push(`${indent}Not uploaded:`);
|
|
162
|
+
for (const p of summary.paths.slice(0, MAX_LISTED_PATHS)) {
|
|
163
|
+
lines.push(`${indent} - ${p}`);
|
|
164
|
+
}
|
|
165
|
+
const unnamed = summary.count - Math.min(summary.paths.length, MAX_LISTED_PATHS);
|
|
166
|
+
if (unnamed > 0)
|
|
167
|
+
lines.push(`${indent} … and ${unnamed} more`);
|
|
168
|
+
}
|
|
169
|
+
else {
|
|
170
|
+
lines.push(`${indent}New files stay on this machine and were not uploaded.`);
|
|
171
|
+
}
|
|
172
|
+
if (summary.upgradeUrl)
|
|
173
|
+
lines.push(`${indent}Upgrade: ${summary.upgradeUrl}`);
|
|
174
|
+
return lines;
|
|
175
|
+
}
|
|
176
|
+
/** "1 new file" / "3 new files", or "new files" when only a notice said so. */
|
|
177
|
+
export function describeRefusedCount(summary) {
|
|
178
|
+
if (summary.paths.length === 0 && summary.count <= 1)
|
|
179
|
+
return "new files";
|
|
180
|
+
return `${summary.count} new file${summary.count === 1 ? "" : "s"}`;
|
|
181
|
+
}
|
|
182
|
+
/** {@link describeRefusedCount} with its verb: "1 new file was", "new files were". */
|
|
183
|
+
export function describeRefusedFiles(summary) {
|
|
184
|
+
const subject = describeRefusedCount(summary);
|
|
185
|
+
return `${subject} ${subject === "1 new file" ? "was" : "were"}`;
|
|
186
|
+
}
|
|
187
|
+
//# sourceMappingURL=plan-limit-refusals.js.map
|
|
@@ -20,6 +20,8 @@
|
|
|
20
20
|
* An absent field is UNKNOWN, so nothing here ever infers over-limit from
|
|
21
21
|
* missing data (hq-absent-field-never-means-constraining-value).
|
|
22
22
|
*/
|
|
23
|
+
import { parseFreeMonth, type PlanLimitFreeMonth } from "./free-month.js";
|
|
24
|
+
export { parseFreeMonth, type PlanLimitFreeMonth };
|
|
23
25
|
/**
|
|
24
26
|
* Dimensions the bind notice names with a remedy. Owner decision 7 (2026-09-17)
|
|
25
27
|
* fixes the nagging set at these four: `deployments` and `storageBytes` are
|
|
@@ -38,11 +40,6 @@ export interface PlanLimitMembers {
|
|
|
38
40
|
}
|
|
39
41
|
/** @deprecated US-040 — alias of {@link PlanLimitMembers}. */
|
|
40
42
|
export type PlanLockMembers = PlanLimitMembers;
|
|
41
|
-
export interface PlanLimitFreeMonth {
|
|
42
|
-
eligible: boolean;
|
|
43
|
-
trialDays?: number;
|
|
44
|
-
url?: string;
|
|
45
|
-
}
|
|
46
43
|
export interface PlanLimitStatus {
|
|
47
44
|
companySlug: string;
|
|
48
45
|
companyUid?: string;
|
|
@@ -78,7 +75,6 @@ export declare const STARTER_AGENT_TARGET = 0;
|
|
|
78
75
|
export declare const DEFAULT_UPGRADE_URL = "https://hq.computer/billing";
|
|
79
76
|
/** The paid plan the nag sends owners to. Copy lives in ONE place. */
|
|
80
77
|
export declare const WORKFORCE_PLAN_LABEL = "HQ Workforce ($500/mo)";
|
|
81
|
-
export declare function parseFreeMonth(value: unknown): PlanLimitFreeMonth | undefined;
|
|
82
78
|
/**
|
|
83
79
|
* Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
|
|
84
80
|
* `/membership/me` body. No matching row is UNKNOWN. Does not read `planLock`.
|
|
@@ -22,7 +22,12 @@
|
|
|
22
22
|
*/
|
|
23
23
|
import chalk from "chalk";
|
|
24
24
|
import { vaultApiFetch } from "../../utils/vault-api.js";
|
|
25
|
+
import { parseFreeMonth } from "./free-month.js";
|
|
25
26
|
import { selectPlanLimitUpgradeUrl } from "./plan-limit-upgrade-url.js";
|
|
27
|
+
// The free-month parser lives in a leaf module so the plan-gate 402 decoder can
|
|
28
|
+
// share it without importing the HTTP layer; re-exported so existing callers
|
|
29
|
+
// keep importing it from here.
|
|
30
|
+
export { parseFreeMonth };
|
|
26
31
|
/**
|
|
27
32
|
* Dimensions the bind notice names with a remedy. Owner decision 7 (2026-09-17)
|
|
28
33
|
* fixes the nagging set at these four: `deployments` and `storageBytes` are
|
|
@@ -98,22 +103,6 @@ function rowIsOver(value) {
|
|
|
98
103
|
const limit = finiteNumber(rec.limit);
|
|
99
104
|
return used !== null && limit !== null && used > limit;
|
|
100
105
|
}
|
|
101
|
-
export function parseFreeMonth(value) {
|
|
102
|
-
const rec = asRecord(value);
|
|
103
|
-
if (!rec)
|
|
104
|
-
return undefined;
|
|
105
|
-
if (rec.eligible !== true)
|
|
106
|
-
return undefined;
|
|
107
|
-
const url = typeof rec.url === "string" && rec.url.trim().length > 0
|
|
108
|
-
? rec.url.trim()
|
|
109
|
-
: undefined;
|
|
110
|
-
const trialDays = finiteNumber(rec.trialDays) ?? undefined;
|
|
111
|
-
return {
|
|
112
|
-
eligible: true,
|
|
113
|
-
...(trialDays !== undefined ? { trialDays } : {}),
|
|
114
|
-
...(url ? { url } : {}),
|
|
115
|
-
};
|
|
116
|
-
}
|
|
117
106
|
/**
|
|
118
107
|
* Pick the membership row for `companySlug` (or `cmp_…` uid) out of a decoded
|
|
119
108
|
* `/membership/me` body. No matching row is UNKNOWN. Does not read `planLock`.
|
|
@@ -66,6 +66,27 @@ export type PatchResult = {
|
|
|
66
66
|
* change, the file is returned unchanged so a rerun writes nothing.
|
|
67
67
|
*/
|
|
68
68
|
export declare function patchEntityFile(content: string, patch: EntityPatch): PatchResult;
|
|
69
|
+
/** A namespaced record in a source system, as the cloud schema's `external_refs` holds it. */
|
|
70
|
+
export interface ExternalRef {
|
|
71
|
+
system: string;
|
|
72
|
+
namespace: string;
|
|
73
|
+
objectType?: string;
|
|
74
|
+
recordId: string;
|
|
75
|
+
}
|
|
76
|
+
export interface IdentityPatch {
|
|
77
|
+
externalIds: Record<string, string>;
|
|
78
|
+
webDomains: string[];
|
|
79
|
+
/** When set, the whole `external_refs` list; when absent, the key is left alone. */
|
|
80
|
+
externalRefs?: ExternalRef[];
|
|
81
|
+
lastUpdated: string;
|
|
82
|
+
}
|
|
83
|
+
/**
|
|
84
|
+
* Set `external_ids` and `web_domains` on an existing, valid entity file.
|
|
85
|
+
* A key whose value is already equal is left as it is; an absent key is
|
|
86
|
+
* appended at the end of the front matter. Nothing else changes except
|
|
87
|
+
* `last_updated`. When neither key changes the file is returned unchanged.
|
|
88
|
+
*/
|
|
89
|
+
export declare function setIdentityKeys(content: string, patch: IdentityPatch): PatchResult;
|
|
69
90
|
export interface SignalBackRef {
|
|
70
91
|
signalType: string;
|
|
71
92
|
preview: string;
|
|
@@ -206,6 +206,106 @@ function checkPatched(original, next, aliases, signalCount, lastUpdated) {
|
|
|
206
206
|
return "patch_last_updated";
|
|
207
207
|
return null;
|
|
208
208
|
}
|
|
209
|
+
function renderExternalIds(ids) {
|
|
210
|
+
const entries = Object.entries(ids);
|
|
211
|
+
if (entries.length === 0)
|
|
212
|
+
return "external_ids: {}";
|
|
213
|
+
return ["external_ids:", ...entries.map(([key, value]) => ` ${key}: ${quote(value)}`)].join("\n");
|
|
214
|
+
}
|
|
215
|
+
function renderExternalRefs(refs) {
|
|
216
|
+
if (refs.length === 0)
|
|
217
|
+
return "external_refs: []";
|
|
218
|
+
const lines = ["external_refs:"];
|
|
219
|
+
for (const ref of refs) {
|
|
220
|
+
lines.push(` - system: ${quote(ref.system)}`, ` namespace: ${quote(ref.namespace)}`);
|
|
221
|
+
if (ref.objectType !== undefined)
|
|
222
|
+
lines.push(` objectType: ${quote(ref.objectType)}`);
|
|
223
|
+
lines.push(` recordId: ${quote(ref.recordId)}`);
|
|
224
|
+
}
|
|
225
|
+
return lines.join("\n");
|
|
226
|
+
}
|
|
227
|
+
function renderWebDomains(domains) {
|
|
228
|
+
if (domains.length === 0)
|
|
229
|
+
return "web_domains: []";
|
|
230
|
+
return ["web_domains:", ...domains.map((domain) => ` - ${quote(domain)}`)].join("\n");
|
|
231
|
+
}
|
|
232
|
+
/**
|
|
233
|
+
* Set `external_ids` and `web_domains` on an existing, valid entity file.
|
|
234
|
+
* A key whose value is already equal is left as it is; an absent key is
|
|
235
|
+
* appended at the end of the front matter. Nothing else changes except
|
|
236
|
+
* `last_updated`. When neither key changes the file is returned unchanged.
|
|
237
|
+
*/
|
|
238
|
+
export function setIdentityKeys(content, patch) {
|
|
239
|
+
const match = FRONTMATTER_RE.exec(content);
|
|
240
|
+
const parsed = parseEntityFile(content);
|
|
241
|
+
if (!match || !parsed)
|
|
242
|
+
return { ok: false, reason: "unparseable" };
|
|
243
|
+
const before = validateEntityFrontmatter(parsed.frontmatter);
|
|
244
|
+
if (typeof before === "string")
|
|
245
|
+
return { ok: false, reason: `invalid_existing:${before}` };
|
|
246
|
+
for (const key of Object.keys(patch.externalIds)) {
|
|
247
|
+
if (!/^[A-Za-z0-9_-]+$/.test(key))
|
|
248
|
+
return { ok: false, reason: "external_ids_key" };
|
|
249
|
+
}
|
|
250
|
+
const currentIds = (parsed.frontmatter.external_ids ?? {});
|
|
251
|
+
const currentDomains = (parsed.frontmatter.web_domains ?? []);
|
|
252
|
+
const currentRefs = parsed.frontmatter.external_refs ?? [];
|
|
253
|
+
const idsChanged = !isDeepStrictEqual(currentIds, patch.externalIds);
|
|
254
|
+
const domainsChanged = !isDeepStrictEqual(currentDomains, patch.webDomains);
|
|
255
|
+
const refsChanged = patch.externalRefs !== undefined && !isDeepStrictEqual(currentRefs, patch.externalRefs);
|
|
256
|
+
if (!idsChanged && !domainsChanged && !refsChanged)
|
|
257
|
+
return { ok: true, content, changed: false };
|
|
258
|
+
const newline = content.includes("\r\n") ? "\r\n" : "\n";
|
|
259
|
+
let lines = match[1].split(/\r?\n/);
|
|
260
|
+
if (idsChanged) {
|
|
261
|
+
const rendered = renderExternalIds(patch.externalIds);
|
|
262
|
+
lines = replaceTopLevelKey(lines, "external_ids", rendered) ?? [...lines, ...rendered.split("\n")];
|
|
263
|
+
}
|
|
264
|
+
if (domainsChanged) {
|
|
265
|
+
const rendered = renderWebDomains(patch.webDomains);
|
|
266
|
+
lines = replaceTopLevelKey(lines, "web_domains", rendered) ?? [...lines, ...rendered.split("\n")];
|
|
267
|
+
}
|
|
268
|
+
if (refsChanged) {
|
|
269
|
+
const rendered = renderExternalRefs(patch.externalRefs);
|
|
270
|
+
lines = replaceTopLevelKey(lines, "external_refs", rendered) ?? [...lines, ...rendered.split("\n")];
|
|
271
|
+
}
|
|
272
|
+
const stamped = replaceTopLevelKey(lines, "last_updated", `last_updated: ${patch.lastUpdated}`);
|
|
273
|
+
if (!stamped)
|
|
274
|
+
return { ok: false, reason: "missing_owned_key" };
|
|
275
|
+
lines = stamped;
|
|
276
|
+
const head = content.slice(0, content.indexOf(match[1]));
|
|
277
|
+
const tail = content.slice(content.indexOf(match[1]) + match[1].length);
|
|
278
|
+
const next = `${head}${lines.join(newline)}${tail}`;
|
|
279
|
+
const reparsed = parseEntityFile(next);
|
|
280
|
+
if (!reparsed)
|
|
281
|
+
return { ok: false, reason: "patch_unparseable" };
|
|
282
|
+
if (typeof validateEntityFrontmatter(reparsed.frontmatter) === "string")
|
|
283
|
+
return { ok: false, reason: "patch_invalid" };
|
|
284
|
+
const keys = new Set([...Object.keys(parsed.frontmatter), ...Object.keys(reparsed.frontmatter)]);
|
|
285
|
+
for (const key of keys) {
|
|
286
|
+
if (key === "external_ids" || key === "web_domains" || key === "last_updated")
|
|
287
|
+
continue;
|
|
288
|
+
if (key === "external_refs" && patch.externalRefs !== undefined)
|
|
289
|
+
continue;
|
|
290
|
+
if (!isDeepStrictEqual(parsed.frontmatter[key], reparsed.frontmatter[key])) {
|
|
291
|
+
return { ok: false, reason: `patch_changed_key:${key}` };
|
|
292
|
+
}
|
|
293
|
+
}
|
|
294
|
+
if (!isDeepStrictEqual(reparsed.frontmatter.external_ids ?? {}, patch.externalIds)) {
|
|
295
|
+
return { ok: false, reason: "patch_external_ids" };
|
|
296
|
+
}
|
|
297
|
+
if (!isDeepStrictEqual(reparsed.frontmatter.web_domains ?? [], patch.webDomains)) {
|
|
298
|
+
return { ok: false, reason: "patch_web_domains" };
|
|
299
|
+
}
|
|
300
|
+
if (refsChanged && !isDeepStrictEqual(reparsed.frontmatter.external_refs ?? [], patch.externalRefs)) {
|
|
301
|
+
return { ok: false, reason: "patch_external_refs" };
|
|
302
|
+
}
|
|
303
|
+
if (reparsed.frontmatter.last_updated !== patch.lastUpdated)
|
|
304
|
+
return { ok: false, reason: "patch_last_updated" };
|
|
305
|
+
if (reparsed.body !== parsed.body)
|
|
306
|
+
return { ok: false, reason: "patch_body" };
|
|
307
|
+
return { ok: true, content: next, changed: true };
|
|
308
|
+
}
|
|
209
309
|
/** hq-pro `previewSignalContent`: first 80 characters, one line. */
|
|
210
310
|
export function previewSignalContent(canonicalContent) {
|
|
211
311
|
const collapsed = canonicalContent.replace(/\s+/g, " ").trim();
|